- orders/actions: checkBendingStock() 서버 액션 추가 - orders/index: BendingStockItem 타입 및 함수 export - 수주 상세페이지: 절곡품 재고 현황 카드 (충족/부족 뱃지, 테이블) - 수주확정 이후 상태에서 자동 로드
1259 lines
40 KiB
TypeScript
1259 lines
40 KiB
TypeScript
'use server';
|
|
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import { buildApiUrl } from '@/lib/api/query-params';
|
|
import type { PaginatedApiResponse } from '@/lib/api/types';
|
|
import { formatDate } from '@/lib/utils/date';
|
|
|
|
// ============================================================================
|
|
// API 타입 정의
|
|
// ============================================================================
|
|
|
|
interface ApiOrder {
|
|
id: number;
|
|
tenant_id: number;
|
|
quote_id: number | null;
|
|
order_no: string;
|
|
order_type_code: string;
|
|
status_code: string;
|
|
category_code: string | null;
|
|
client_id: number | null;
|
|
client_name: string | null;
|
|
client_contact: string | null;
|
|
site_name: string | null;
|
|
quantity: number;
|
|
root_nodes_sum_quantity: number | null;
|
|
supply_amount: number;
|
|
tax_amount: number;
|
|
total_amount: number;
|
|
discount_rate: number;
|
|
discount_amount: number;
|
|
delivery_date: string | null;
|
|
delivery_method_code: string | null;
|
|
delivery_method_label?: string; // API에서 조회한 배송방식 라벨
|
|
shipping_cost_label?: string; // API에서 조회한 운임비용 라벨
|
|
received_at: string | null;
|
|
memo: string | null;
|
|
remarks: string | null;
|
|
note: string | null;
|
|
options: {
|
|
shipping_cost_code?: string;
|
|
receiver?: string;
|
|
receiver_contact?: string;
|
|
shipping_address?: string;
|
|
shipping_address_detail?: string;
|
|
manager_name?: string;
|
|
} | null;
|
|
created_by: number | null;
|
|
updated_by: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
client?: ApiClient | null;
|
|
items?: ApiOrderItem[];
|
|
root_nodes?: ApiOrderNode[];
|
|
quote?: ApiQuote | null;
|
|
}
|
|
|
|
interface ApiOrderItem {
|
|
id: number;
|
|
order_id: number;
|
|
order_node_id: number | null;
|
|
item_id: number | null;
|
|
item_code: string | null;
|
|
item_name: string;
|
|
specification: string | null;
|
|
// 제품-부품 매핑용 코드
|
|
floor_code: string | null;
|
|
symbol_code: string | null;
|
|
quantity: number;
|
|
unit: string | null;
|
|
unit_price: number;
|
|
supply_amount: number;
|
|
tax_amount: number;
|
|
total_amount: number;
|
|
sort_order: number;
|
|
}
|
|
|
|
interface ApiOrderNode {
|
|
id: number;
|
|
order_id: number;
|
|
parent_id: number | null;
|
|
node_type: string;
|
|
code: string;
|
|
name: string;
|
|
status_code: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
total_price: number;
|
|
options: Record<string, unknown> | null;
|
|
depth: number;
|
|
sort_order: number;
|
|
children?: ApiOrderNode[];
|
|
items?: ApiOrderItem[];
|
|
}
|
|
|
|
interface ApiClient {
|
|
id: number;
|
|
name: string;
|
|
business_no?: string;
|
|
contact_person?: string;
|
|
manager_name?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
grade?: string;
|
|
}
|
|
|
|
interface ApiQuote {
|
|
id: number;
|
|
quote_no: string;
|
|
quote_number?: string;
|
|
site_name: string | null;
|
|
calculation_inputs?: {
|
|
items?: Array<{
|
|
productCategory?: string;
|
|
productName?: string;
|
|
openWidth?: string;
|
|
openHeight?: string;
|
|
quantity?: number;
|
|
floor?: string;
|
|
code?: string;
|
|
}>;
|
|
} | null;
|
|
}
|
|
|
|
// 견적 목록 조회용 상세 타입
|
|
interface ApiQuoteForSelect {
|
|
id: number;
|
|
quote_number: string;
|
|
registration_date: string;
|
|
status: string;
|
|
client_id: number | null;
|
|
client_name: string | null;
|
|
site_name: string | null;
|
|
supply_amount: number;
|
|
tax_amount: number;
|
|
total_amount: number;
|
|
item_count?: number;
|
|
author?: string | null;
|
|
manager?: string | null;
|
|
contact?: string | null;
|
|
client?: {
|
|
id: number;
|
|
name: string;
|
|
contact_person?: string; // 담당자
|
|
phone?: string;
|
|
grade?: string; // 등급 (A, B, C)
|
|
} | null;
|
|
items?: ApiQuoteItem[];
|
|
calculation_inputs?: {
|
|
items?: Array<{
|
|
productCategory?: string;
|
|
productCode?: string;
|
|
productName?: string;
|
|
openWidth?: string;
|
|
openHeight?: string;
|
|
quantity?: number;
|
|
floor?: string;
|
|
code?: string;
|
|
}>;
|
|
} | null;
|
|
}
|
|
|
|
interface ApiQuoteItem {
|
|
id: number;
|
|
item_id?: number | null; // Items Master 참조 ID
|
|
item_code?: string;
|
|
item_name: string;
|
|
type_code?: string;
|
|
symbol?: string;
|
|
note?: string; // "5F FSS-01" 형태 (floor + code)
|
|
specification?: string;
|
|
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
|
|
calculated_quantity?: number;
|
|
quantity?: number; // fallback
|
|
unit?: string;
|
|
unit_price: number;
|
|
total_price?: number;
|
|
// 수주 품목에서 사용하는 필드명
|
|
supply_amount?: number;
|
|
tax_amount?: number;
|
|
total_amount?: number;
|
|
}
|
|
|
|
interface ApiWorkOrder {
|
|
id: number;
|
|
tenant_id: number;
|
|
work_order_no: string;
|
|
sales_order_id: number;
|
|
project_name: string | null;
|
|
process_id: number | null;
|
|
process_type: string;
|
|
status: string;
|
|
assignee_id: number | null;
|
|
team_id: number | null;
|
|
scheduled_date: string | null;
|
|
memo: string | null;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
assignee?: { id: number; name: string } | null;
|
|
team?: { id: number; name: string } | null;
|
|
process?: { id: number; process_name: string } | null;
|
|
}
|
|
|
|
interface ApiProductionOrderResponse {
|
|
work_order?: ApiWorkOrder;
|
|
work_orders?: ApiWorkOrder[];
|
|
order: ApiOrder;
|
|
}
|
|
|
|
interface ApiOrderStats {
|
|
total: number;
|
|
draft: number;
|
|
confirmed: number;
|
|
in_progress: number;
|
|
completed: number;
|
|
cancelled: number;
|
|
total_amount: number;
|
|
confirmed_amount: number;
|
|
}
|
|
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
message: string;
|
|
data: T;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Frontend 타입 정의
|
|
// ============================================================================
|
|
|
|
// 수주 상태 타입 (API와 매핑)
|
|
export type OrderStatus =
|
|
| 'order_registered' // DRAFT
|
|
| 'order_confirmed' // CONFIRMED
|
|
| 'production_ordered' // IN_PROGRESS
|
|
| 'in_production' // IN_PRODUCTION
|
|
| 'produced' // PRODUCED
|
|
| 'shipping' // SHIPPING
|
|
| 'shipped' // SHIPPED
|
|
| 'completed' // COMPLETED
|
|
| 'rework' // (세부 - 레거시)
|
|
| 'work_completed' // (세부 - 레거시)
|
|
| 'cancelled'; // CANCELLED
|
|
|
|
export interface Order {
|
|
id: string;
|
|
lotNumber: string; // order_no
|
|
quoteNumber: string; // quote.quote_no
|
|
quoteId?: number;
|
|
orderDate: string; // received_at
|
|
client: string; // client_name
|
|
clientId?: number;
|
|
siteName: string; // site_name
|
|
status: OrderStatus;
|
|
statusCode: string; // 원본 status_code
|
|
expectedShipDate?: string; // delivery_date
|
|
deliveryMethod?: string; // delivery_method_code
|
|
deliveryMethodLabel?: string; // 배송방식 라벨 (API에서 조회)
|
|
amount: number; // total_amount
|
|
supplyAmount: number;
|
|
taxAmount: number;
|
|
itemCount: number; // items.length
|
|
hasReceivable?: boolean; // 미수 여부 (추후 구현)
|
|
memo?: string;
|
|
remarks?: string;
|
|
note?: string;
|
|
items?: OrderItem[];
|
|
nodes?: OrderNode[];
|
|
// 목록 페이지용 추가 필드
|
|
productName?: string; // 제품명 (첫 번째 품목명)
|
|
receiverAddress?: string; // 수신주소
|
|
receiverPlace?: string; // 수신처 (전화번호)
|
|
frameCount?: number; // 틀수 (수량)
|
|
// 상세 페이지용 추가 필드
|
|
manager?: string; // 담당자
|
|
contact?: string; // 연락처 (client_contact)
|
|
deliveryRequestDate?: string; // 납품요청일
|
|
shippingCost?: string; // 운임비용 (코드)
|
|
shippingCostLabel?: string; // 운임비용 (라벨)
|
|
receiver?: string; // 수신자
|
|
receiverContact?: string; // 수신처 연락처
|
|
address?: string; // 수신처 주소
|
|
addressDetail?: string; // 상세주소
|
|
subtotal?: number; // 소계 (supply_amount와 동일)
|
|
discountRate?: number; // 할인율
|
|
totalAmount?: number; // 총금액 (amount와 동일하지만 명시적)
|
|
// 제품 정보 (견적의 calculation_inputs에서 가져옴)
|
|
products?: Array<{
|
|
productName: string;
|
|
productCategory?: string;
|
|
openWidth?: string;
|
|
openHeight?: string;
|
|
quantity: number;
|
|
floor?: string;
|
|
code?: string;
|
|
}>;
|
|
}
|
|
|
|
export interface OrderNode {
|
|
id: number;
|
|
parentId: number | null;
|
|
nodeType: string;
|
|
code: string;
|
|
name: string;
|
|
statusCode: string;
|
|
quantity: number;
|
|
unitPrice: number;
|
|
totalPrice: number;
|
|
options: Record<string, unknown> | null;
|
|
depth: number;
|
|
sortOrder: number;
|
|
children: OrderNode[];
|
|
items: OrderItem[];
|
|
}
|
|
|
|
export interface OrderItem {
|
|
id: string;
|
|
itemId?: number;
|
|
itemCode?: string; // 품목코드
|
|
itemName: string;
|
|
specification?: string;
|
|
spec?: string; // specification alias
|
|
type?: string; // 층 (layer)
|
|
symbol?: string; // 부호
|
|
quantity: number;
|
|
unit?: string;
|
|
unitPrice: number;
|
|
supplyAmount: number;
|
|
taxAmount: number;
|
|
totalAmount: number;
|
|
amount?: number; // totalAmount alias
|
|
sortOrder: number;
|
|
width?: number; // 가로 (mm) - ItemAddDialog 호환
|
|
height?: number; // 세로 (mm) - ItemAddDialog 호환
|
|
}
|
|
|
|
export interface OrderFormData {
|
|
orderTypeCode?: string;
|
|
categoryCode?: string;
|
|
clientId?: number;
|
|
clientName?: string;
|
|
clientContact?: string;
|
|
siteName?: string;
|
|
supplyAmount?: number;
|
|
taxAmount?: number;
|
|
totalAmount?: number;
|
|
discountRate?: number;
|
|
discountAmount?: number;
|
|
deliveryDate?: string;
|
|
deliveryMethodCode?: string;
|
|
receivedAt?: string;
|
|
memo?: string;
|
|
remarks?: string;
|
|
note?: string;
|
|
items?: OrderItemFormData[];
|
|
// 수정 페이지용 추가 필드
|
|
expectedShipDate?: string; // 출고예정일
|
|
deliveryRequestDate?: string; // 납품요청일
|
|
deliveryMethod?: string; // 배송방식 (deliveryMethodCode alias)
|
|
shippingCost?: string; // 운임비용
|
|
receiver?: string; // 수신자
|
|
receiverContact?: string; // 수신처 연락처
|
|
address?: string; // 수신처 주소
|
|
addressDetail?: string; // 상세주소
|
|
}
|
|
|
|
export interface OrderItemFormData {
|
|
itemId?: number;
|
|
itemCode?: string; // 품목코드
|
|
itemName: string;
|
|
specification?: string;
|
|
quantity: number;
|
|
unit?: string;
|
|
unitPrice: number;
|
|
}
|
|
|
|
export interface OrderStats {
|
|
total: number;
|
|
draft: number;
|
|
confirmed: number;
|
|
inProgress: number;
|
|
completed: number;
|
|
cancelled: number;
|
|
totalAmount: number;
|
|
confirmedAmount: number;
|
|
// 대시보드 통계용 추가 필드 (API에서 선택적으로 반환)
|
|
thisMonthAmount?: number;
|
|
splitPending?: number;
|
|
productionPending?: number;
|
|
shipPending?: number;
|
|
}
|
|
|
|
// 견적→수주 변환용
|
|
export interface CreateFromQuoteData {
|
|
deliveryDate?: string;
|
|
memo?: string;
|
|
}
|
|
|
|
// 생산지시 생성용
|
|
export interface CreateProductionOrderData {
|
|
processId?: number;
|
|
processIds?: number[]; // 공정별 다중 작업지시 생성용
|
|
priority?: 'urgent' | 'high' | 'normal' | 'low';
|
|
assigneeId?: number;
|
|
assigneeIds?: number[]; // 다중 담당자 선택용
|
|
teamId?: number;
|
|
departmentId?: number; // 부서 ID
|
|
scheduledDate?: string;
|
|
memo?: string;
|
|
}
|
|
|
|
// 생산지시(작업지시) 타입
|
|
export interface WorkOrder {
|
|
id: string;
|
|
workOrderNo: string;
|
|
salesOrderId: number;
|
|
projectName: string | null;
|
|
processId?: number;
|
|
processType: string;
|
|
status: string;
|
|
assigneeId?: number;
|
|
assigneeName?: string;
|
|
teamId?: number;
|
|
teamName?: string;
|
|
scheduledDate?: string;
|
|
memo?: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
process?: { id: number; processName: string };
|
|
}
|
|
|
|
// 생산지시 생성 결과
|
|
export interface ProductionOrderResult {
|
|
workOrder?: WorkOrder;
|
|
workOrders?: WorkOrder[];
|
|
order: Order;
|
|
}
|
|
|
|
// 견적 선택용 타입 (QuotationSelectDialog용)
|
|
export interface QuotationForSelect {
|
|
id: string;
|
|
quoteNumber: string; // KD-PR-XXXXXX-XX
|
|
grade: string; // A(우량), B(관리), C(주의)
|
|
clientId: string | null; // 발주처 ID
|
|
client: string; // 발주처명
|
|
siteName: string; // 현장명
|
|
amount: number; // 총 금액
|
|
itemCount: number; // 품목 수
|
|
registrationDate: string; // 견적일
|
|
manager?: string; // 담당자
|
|
contact?: string; // 연락처
|
|
items?: QuotationItem[]; // 품목 내역
|
|
calculationInputs?: {
|
|
items?: Array<{
|
|
productCategory?: string;
|
|
productCode?: string;
|
|
productName?: string;
|
|
openWidth?: string;
|
|
openHeight?: string;
|
|
quantity?: number;
|
|
floor?: string;
|
|
code?: string;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
export interface QuotationItem {
|
|
id: string;
|
|
itemId?: string | null; // Items Master 참조 ID
|
|
itemCode: string;
|
|
itemName: string;
|
|
type: string; // 종
|
|
symbol: string; // 부호
|
|
spec: string; // 규격
|
|
quantity: number;
|
|
unit: string;
|
|
unitPrice: number;
|
|
amount: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 상태 매핑
|
|
// ============================================================================
|
|
|
|
const API_TO_FRONTEND_STATUS: Record<string, OrderStatus> = {
|
|
'DRAFT': 'order_registered',
|
|
'CONFIRMED': 'order_confirmed',
|
|
'IN_PROGRESS': 'production_ordered',
|
|
'IN_PRODUCTION': 'in_production',
|
|
'PRODUCED': 'produced',
|
|
'SHIPPING': 'shipping',
|
|
'SHIPPED': 'shipped',
|
|
'COMPLETED': 'completed',
|
|
'CANCELLED': 'cancelled',
|
|
};
|
|
|
|
const FRONTEND_TO_API_STATUS: Record<OrderStatus, string> = {
|
|
'order_registered': 'DRAFT',
|
|
'order_confirmed': 'CONFIRMED',
|
|
'production_ordered': 'IN_PROGRESS',
|
|
'in_production': 'IN_PRODUCTION',
|
|
'produced': 'PRODUCED',
|
|
'shipping': 'SHIPPING',
|
|
'shipped': 'SHIPPED',
|
|
'completed': 'COMPLETED',
|
|
'rework': 'IN_PROGRESS',
|
|
'work_completed': 'IN_PROGRESS',
|
|
'cancelled': 'CANCELLED',
|
|
};
|
|
|
|
// ============================================================================
|
|
// 데이터 변환 함수
|
|
// ============================================================================
|
|
|
|
function transformApiToFrontend(apiData: ApiOrder): Order {
|
|
return {
|
|
id: String(apiData.id),
|
|
lotNumber: apiData.order_no,
|
|
quoteNumber: apiData.quote?.quote_number || '',
|
|
quoteId: apiData.quote_id ?? undefined,
|
|
orderDate: apiData.received_at || formatDate(apiData.created_at),
|
|
client: apiData.client_name || apiData.client?.name || '',
|
|
clientId: apiData.client_id ?? undefined,
|
|
siteName: apiData.site_name || '',
|
|
status: API_TO_FRONTEND_STATUS[apiData.status_code] || 'order_registered',
|
|
statusCode: apiData.status_code,
|
|
expectedShipDate: apiData.delivery_date ?? undefined,
|
|
deliveryMethod: apiData.delivery_method_code ?? undefined,
|
|
deliveryMethodLabel: apiData.delivery_method_label ?? apiData.delivery_method_code ?? undefined,
|
|
amount: apiData.total_amount,
|
|
supplyAmount: apiData.supply_amount,
|
|
taxAmount: apiData.tax_amount,
|
|
itemCount: apiData.items?.length || 0,
|
|
hasReceivable: false, // 추후 구현
|
|
memo: apiData.memo ?? undefined,
|
|
remarks: apiData.remarks ?? undefined,
|
|
note: apiData.note ?? undefined,
|
|
items: apiData.items?.map(transformItemApiToFrontend) || [],
|
|
nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [],
|
|
// 목록 페이지용 추가 필드: 첫 root_node의 options.product_name (FG 제품명)
|
|
productName: (apiData.root_nodes?.[0]?.options?.product_name as string) || undefined,
|
|
receiverAddress: apiData.options?.shipping_address ?? undefined,
|
|
receiverPlace: apiData.options?.receiver_contact ?? undefined,
|
|
frameCount: apiData.root_nodes_sum_quantity ?? apiData.quantity ?? undefined,
|
|
// 상세 페이지용 추가 필드 (API에서 매핑)
|
|
manager: apiData.options?.manager_name ?? apiData.client?.manager_name ?? undefined,
|
|
contact: apiData.client_contact ?? apiData.client?.phone ?? undefined,
|
|
deliveryRequestDate: apiData.delivery_date ?? undefined, // delivery_date를 공유
|
|
// options JSON에서 추출
|
|
shippingCost: apiData.options?.shipping_cost_code ?? undefined,
|
|
shippingCostLabel: apiData.shipping_cost_label ?? undefined,
|
|
receiver: apiData.options?.receiver ?? undefined,
|
|
receiverContact: apiData.options?.receiver_contact ?? undefined,
|
|
address: apiData.options?.shipping_address ?? undefined,
|
|
addressDetail: apiData.options?.shipping_address_detail ?? undefined,
|
|
subtotal: apiData.supply_amount,
|
|
discountRate: apiData.discount_rate,
|
|
totalAmount: apiData.total_amount,
|
|
// 제품 정보 (견적의 calculation_inputs에서 추출)
|
|
products: apiData.quote?.calculation_inputs?.items?.map(item => ({
|
|
productName: item.productName || '',
|
|
productCategory: item.productCategory,
|
|
openWidth: item.openWidth,
|
|
openHeight: item.openHeight,
|
|
quantity: item.quantity || 1,
|
|
floor: item.floor,
|
|
code: item.code,
|
|
})) || [],
|
|
};
|
|
}
|
|
|
|
function transformNodeApiToFrontend(apiNode: ApiOrderNode): OrderNode {
|
|
return {
|
|
id: apiNode.id,
|
|
parentId: apiNode.parent_id,
|
|
nodeType: apiNode.node_type,
|
|
code: apiNode.code,
|
|
name: apiNode.name,
|
|
statusCode: apiNode.status_code,
|
|
quantity: apiNode.quantity,
|
|
unitPrice: apiNode.unit_price,
|
|
totalPrice: apiNode.total_price,
|
|
options: apiNode.options,
|
|
depth: apiNode.depth,
|
|
sortOrder: apiNode.sort_order,
|
|
children: (apiNode.children || []).map(transformNodeApiToFrontend),
|
|
items: (apiNode.items || []).map(transformItemApiToFrontend),
|
|
};
|
|
}
|
|
|
|
function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem {
|
|
return {
|
|
id: String(apiItem.id),
|
|
itemId: apiItem.item_id ?? undefined,
|
|
itemCode: apiItem.item_code ?? undefined,
|
|
itemName: apiItem.item_name,
|
|
specification: apiItem.specification ?? undefined,
|
|
spec: apiItem.specification ?? undefined, // specification alias
|
|
type: apiItem.floor_code ?? undefined, // 층 코드 (제품-부품 매핑용)
|
|
symbol: apiItem.symbol_code ?? undefined, // 부호 코드 (제품-부품 매핑용)
|
|
quantity: apiItem.quantity,
|
|
unit: apiItem.unit ?? undefined,
|
|
unitPrice: apiItem.unit_price,
|
|
supplyAmount: apiItem.supply_amount,
|
|
taxAmount: apiItem.tax_amount,
|
|
totalAmount: apiItem.total_amount,
|
|
amount: apiItem.total_amount, // totalAmount alias
|
|
sortOrder: apiItem.sort_order,
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
// Build result object - only include client_id if explicitly provided
|
|
// to avoid overwriting existing value with null on update
|
|
const result: Record<string, unknown> = {
|
|
quote_id: quoteIdValue,
|
|
order_type_code: formData.orderTypeCode || 'ORDER',
|
|
category_code: formData.categoryCode || null,
|
|
// client_id is conditionally added below
|
|
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,
|
|
// options JSON으로 묶어서 저장 (운임비용, 수신자, 수신처 연락처, 주소)
|
|
options: {
|
|
shipping_cost_code: formData.shippingCost || null,
|
|
receiver: formData.receiver || null,
|
|
receiver_contact: formData.receiverContact || null,
|
|
shipping_address: formData.address || null,
|
|
shipping_address_detail: formData.addressDetail || null,
|
|
manager_name: formData.manager || 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,
|
|
floor_code: item.type || null,
|
|
symbol_code: item.symbol || null,
|
|
};
|
|
}),
|
|
};
|
|
|
|
// Only include client_id if explicitly provided (not undefined)
|
|
// This prevents overwriting existing client_id with null on update
|
|
if (clientId !== undefined) {
|
|
result.client_id = clientIdValue;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function transformWorkOrderApiToFrontend(apiData: ApiWorkOrder): WorkOrder {
|
|
return {
|
|
id: String(apiData.id),
|
|
workOrderNo: apiData.work_order_no,
|
|
salesOrderId: apiData.sales_order_id,
|
|
projectName: apiData.project_name,
|
|
processType: apiData.process_type,
|
|
status: apiData.status,
|
|
assigneeId: apiData.assignee_id ?? undefined,
|
|
assigneeName: apiData.assignee?.name ?? undefined,
|
|
teamId: apiData.team_id ?? undefined,
|
|
teamName: apiData.team?.name ?? undefined,
|
|
scheduledDate: apiData.scheduled_date ?? undefined,
|
|
memo: apiData.memo ?? undefined,
|
|
isActive: apiData.is_active,
|
|
createdAt: apiData.created_at,
|
|
updatedAt: apiData.updated_at,
|
|
processId: apiData.process_id ?? undefined,
|
|
process: apiData.process ? {
|
|
id: apiData.process.id,
|
|
processName: apiData.process.process_name,
|
|
} : undefined,
|
|
};
|
|
}
|
|
|
|
function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect {
|
|
return {
|
|
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,
|
|
itemCount: apiData.item_count || apiData.items?.length || 0,
|
|
registrationDate: apiData.registration_date,
|
|
manager: apiData.manager ?? undefined,
|
|
contact: apiData.contact ?? apiData.client?.phone ?? undefined,
|
|
items: apiData.items?.map(transformQuoteItemForSelect),
|
|
calculationInputs: apiData.calculation_inputs ? {
|
|
items: apiData.calculation_inputs.items?.map(item => ({
|
|
productCategory: item.productCategory,
|
|
productCode: item.productCode,
|
|
productName: item.productName,
|
|
openWidth: item.openWidth,
|
|
openHeight: item.openHeight,
|
|
quantity: item.quantity,
|
|
floor: item.floor,
|
|
code: item.code,
|
|
})),
|
|
} : undefined,
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
// note에서 floor+code 추출: "5F FSS-01" → type="5F", symbol="FSS-01"
|
|
let typeFromNote = apiItem.type_code || '';
|
|
let symbolFromNote = apiItem.symbol || '';
|
|
if (!typeFromNote && !symbolFromNote && apiItem.note) {
|
|
const noteParts = apiItem.note.trim().split(/\s+/);
|
|
if (noteParts.length >= 2) {
|
|
typeFromNote = noteParts[0];
|
|
symbolFromNote = noteParts.slice(1).join(' ');
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: String(apiItem.id),
|
|
itemId: apiItem.item_id ? String(apiItem.item_id) : null,
|
|
itemCode: apiItem.item_code || '',
|
|
itemName: apiItem.item_name,
|
|
type: typeFromNote,
|
|
symbol: symbolFromNote,
|
|
spec: apiItem.specification || '',
|
|
quantity,
|
|
unit: apiItem.unit || 'EA',
|
|
unitPrice,
|
|
amount,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// API 함수
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 수주 목록 조회
|
|
*/
|
|
export async function getOrders(params?: {
|
|
page?: number;
|
|
size?: number;
|
|
q?: string;
|
|
status?: string;
|
|
order_type?: string;
|
|
client_id?: number;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
}): Promise<{
|
|
success: boolean;
|
|
data?: { items: Order[]; total: number; page: number; totalPages: number };
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiStatus = params?.status ? FRONTEND_TO_API_STATUS[params.status as OrderStatus] : undefined;
|
|
const result = await executeServerAction<PaginatedApiResponse<ApiOrder>>({
|
|
url: buildApiUrl('/api/v1/orders', {
|
|
page: params?.page,
|
|
size: params?.size,
|
|
q: params?.q,
|
|
status: apiStatus,
|
|
order_type: params?.order_type,
|
|
client_id: params?.client_id,
|
|
date_from: params?.date_from,
|
|
date_to: params?.date_to,
|
|
}),
|
|
errorMessage: '목록 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.data.data.map(transformApiToFrontend),
|
|
total: result.data.total,
|
|
page: result.data.current_page,
|
|
totalPages: result.data.last_page,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 수주 상세 조회
|
|
*/
|
|
export async function getOrderById(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Order;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${id}`),
|
|
transform: (data: ApiOrder) => transformApiToFrontend(data),
|
|
errorMessage: '조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 생성
|
|
*/
|
|
export async function createOrder(data: OrderFormData | Record<string, unknown>): Promise<{
|
|
success: boolean;
|
|
data?: Order;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiData = transformFrontendToApi(data);
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl('/api/v1/orders'),
|
|
method: 'POST',
|
|
body: apiData,
|
|
transform: (d: ApiOrder) => transformApiToFrontend(d),
|
|
errorMessage: '등록에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 수정
|
|
*/
|
|
export async function updateOrder(id: string, data: OrderFormData | Record<string, unknown>): Promise<{
|
|
success: boolean;
|
|
data?: Order;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiData = transformFrontendToApi(data);
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${id}`),
|
|
method: 'PUT',
|
|
body: apiData,
|
|
transform: (d: ApiOrder) => transformApiToFrontend(d),
|
|
errorMessage: '수정에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 삭제
|
|
*/
|
|
export async function deleteOrder(id: string): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${id}`),
|
|
method: 'DELETE',
|
|
errorMessage: '삭제에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 상태 변경
|
|
*/
|
|
export async function updateOrderStatus(id: string, status: OrderStatus): Promise<{
|
|
success: boolean;
|
|
data?: Order;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiStatus = FRONTEND_TO_API_STATUS[status];
|
|
if (!apiStatus) {
|
|
return { success: false, error: '유효하지 않은 상태입니다.' };
|
|
}
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${id}/status`),
|
|
method: 'PATCH',
|
|
body: { status: apiStatus },
|
|
transform: (d: ApiOrder) => transformApiToFrontend(d),
|
|
errorMessage: '상태 변경에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 통계 조회
|
|
*/
|
|
export async function getOrderStats(): Promise<{
|
|
success: boolean;
|
|
data?: OrderStats;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction<ApiOrderStats>({
|
|
url: buildApiUrl('/api/v1/orders/stats'),
|
|
errorMessage: '통계 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
total: result.data.total,
|
|
draft: result.data.draft,
|
|
confirmed: result.data.confirmed,
|
|
inProgress: result.data.in_progress,
|
|
completed: result.data.completed,
|
|
cancelled: result.data.cancelled,
|
|
totalAmount: result.data.total_amount,
|
|
confirmedAmount: result.data.confirmed_amount,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 수주 일괄 삭제 (Bulk API)
|
|
*/
|
|
export async function deleteOrders(
|
|
ids: string[],
|
|
options?: { force?: boolean }
|
|
): Promise<{
|
|
success: boolean;
|
|
deletedCount?: number;
|
|
skippedCount?: number;
|
|
skippedIds?: number[];
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface BulkDeleteResponse {
|
|
deleted_count: number;
|
|
skipped_count: number;
|
|
skipped_ids: number[];
|
|
}
|
|
const body: Record<string, unknown> = { ids: ids.map(Number) };
|
|
if (options?.force) body.force = true;
|
|
const result = await executeServerAction<BulkDeleteResponse>({
|
|
url: buildApiUrl('/api/v1/orders/bulk'),
|
|
method: 'DELETE',
|
|
body,
|
|
errorMessage: '수주 일괄 삭제에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
deletedCount: result.data.deleted_count,
|
|
skippedCount: result.data.skipped_count,
|
|
skippedIds: result.data.skipped_ids,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 견적에서 수주 생성
|
|
*/
|
|
export async function createOrderFromQuote(
|
|
quoteId: number,
|
|
data?: CreateFromQuoteData
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: Order;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiData: Record<string, unknown> = {};
|
|
if (data?.deliveryDate) apiData.delivery_date = data.deliveryDate;
|
|
if (data?.memo) apiData.memo = data.memo;
|
|
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/from-quote/${quoteId}`),
|
|
method: 'POST',
|
|
body: apiData,
|
|
transform: (d: ApiOrder) => transformApiToFrontend(d),
|
|
errorMessage: '수주 생성에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 생산지시 생성
|
|
*/
|
|
export async function createProductionOrder(
|
|
orderId: string,
|
|
data?: CreateProductionOrderData
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: ProductionOrderResult;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const apiData: Record<string, unknown> = {};
|
|
if (data?.processIds && data.processIds.length > 0) {
|
|
apiData.process_ids = data.processIds;
|
|
} else if (data?.processId) {
|
|
apiData.process_id = data.processId;
|
|
}
|
|
if (data?.priority) apiData.priority = data.priority;
|
|
if (data?.assigneeIds && data.assigneeIds.length > 0) {
|
|
apiData.assignee_ids = data.assigneeIds;
|
|
} else if (data?.assigneeId) {
|
|
apiData.assignee_id = data.assigneeId;
|
|
}
|
|
if (data?.teamId) apiData.team_id = data.teamId;
|
|
if (data?.departmentId) apiData.department_id = data.departmentId;
|
|
if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate;
|
|
if (data?.memo) apiData.memo = data.memo;
|
|
|
|
const result = await executeServerAction<ApiProductionOrderResponse>({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/production-order`),
|
|
method: 'POST',
|
|
body: apiData,
|
|
errorMessage: '생산지시 생성에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
|
|
const responseData: ProductionOrderResult = {
|
|
order: transformApiToFrontend(result.data.order),
|
|
};
|
|
if (result.data.work_orders && result.data.work_orders.length > 0) {
|
|
responseData.workOrders = result.data.work_orders.map(transformWorkOrderApiToFrontend);
|
|
} else if (result.data.work_order) {
|
|
responseData.workOrder = transformWorkOrderApiToFrontend(result.data.work_order);
|
|
}
|
|
return { success: true, data: responseData };
|
|
}
|
|
|
|
/**
|
|
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
|
*/
|
|
export async function revertProductionOrder(
|
|
orderId: string,
|
|
options?: { force?: boolean; reason?: string }
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: {
|
|
order: Order;
|
|
deletedCounts: { workResults: number; workOrderItems: number; workOrders: number };
|
|
previousStatus: string;
|
|
};
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface RevertResponse {
|
|
order: ApiOrder;
|
|
deleted_counts: { work_results: number; work_order_items: number; work_orders: number };
|
|
cancelled_counts?: { work_orders: number; work_order_items: number };
|
|
previous_status: string;
|
|
}
|
|
const body: Record<string, unknown> = {};
|
|
if (options?.force !== undefined) body.force = options.force;
|
|
if (options?.reason) body.reason = options.reason;
|
|
const result = await executeServerAction<RevertResponse>({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/revert-production`),
|
|
method: 'POST',
|
|
body: Object.keys(body).length > 0 ? body : undefined,
|
|
errorMessage: '생산지시 되돌리기에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
order: transformApiToFrontend(result.data.order),
|
|
deletedCounts: {
|
|
workResults: result.data.deleted_counts.work_results,
|
|
workOrderItems: result.data.deleted_counts.work_order_items,
|
|
workOrders: result.data.deleted_counts.work_orders,
|
|
},
|
|
previousStatus: result.data.previous_status,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 수주확정 되돌리기 (수주등록 상태로 변경)
|
|
*/
|
|
export async function revertOrderConfirmation(orderId: string): Promise<{
|
|
success: boolean;
|
|
data?: { order: Order; previousStatus: string };
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface RevertConfirmResponse { order: ApiOrder; previous_status: string }
|
|
const result = await executeServerAction<RevertConfirmResponse>({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/revert-confirmation`),
|
|
method: 'POST',
|
|
errorMessage: '수주확정 되돌리기에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
order: transformApiToFrontend(result.data.order),
|
|
previousStatus: result.data.previous_status,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 절곡 BOM 품목 재고 현황 조회
|
|
*/
|
|
export interface BendingStockItem {
|
|
itemId: number;
|
|
itemCode: string;
|
|
itemName: string;
|
|
unit: string;
|
|
neededQty: number;
|
|
stockQty: number;
|
|
reservedQty: number;
|
|
availableQty: number;
|
|
shortfallQty: number;
|
|
status: 'sufficient' | 'insufficient';
|
|
}
|
|
|
|
export async function checkBendingStock(orderId: string): Promise<{
|
|
success: boolean;
|
|
data?: BendingStockItem[];
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface ApiBendingStockItem {
|
|
item_id: number;
|
|
item_code: string;
|
|
item_name: string;
|
|
unit: string;
|
|
needed_qty: number;
|
|
stock_qty: number;
|
|
reserved_qty: number;
|
|
available_qty: number;
|
|
shortfall_qty: number;
|
|
status: string;
|
|
}
|
|
|
|
const result = await executeServerAction<ApiBendingStockItem[]>({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/bending-stock`),
|
|
errorMessage: '절곡 재고 현황 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
|
|
const items: BendingStockItem[] = result.data.map((item) => ({
|
|
itemId: item.item_id,
|
|
itemCode: item.item_code,
|
|
itemName: item.item_name,
|
|
unit: item.unit,
|
|
neededQty: item.needed_qty,
|
|
stockQty: item.stock_qty,
|
|
reservedQty: item.reserved_qty,
|
|
availableQty: item.available_qty,
|
|
shortfallQty: item.shortfall_qty,
|
|
status: item.status as 'sufficient' | 'insufficient',
|
|
}));
|
|
|
|
return { success: true, data: items };
|
|
}
|
|
|
|
/**
|
|
* 수주 변환용 단일 견적 조회 (ID로 조회)
|
|
* 견적 상세페이지에서 수주등록 버튼 클릭 시 사용
|
|
*/
|
|
export async function getQuoteByIdForSelect(id: string): Promise<{
|
|
success: boolean;
|
|
data?: QuotationForSelect;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}`, { with_items: true }),
|
|
transform: (data: ApiQuoteForSelect) => transformQuoteForSelect(data),
|
|
errorMessage: '견적 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* 수주 변환용 확정 견적 목록 조회
|
|
* QuotationSelectDialog에서 사용
|
|
*/
|
|
export async function getQuotesForSelect(params?: {
|
|
q?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}): Promise<{
|
|
success: boolean;
|
|
data?: { items: QuotationForSelect[]; total: number };
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction<PaginatedApiResponse<ApiQuoteForSelect>>({
|
|
url: buildApiUrl('/api/v1/quotes', {
|
|
status: 'finalized',
|
|
with_items: 'true',
|
|
for_order: 'true',
|
|
q: params?.q,
|
|
page: params?.page,
|
|
size: params?.size || 50,
|
|
}),
|
|
errorMessage: '견적 목록 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.data.data.map(transformQuoteForSelect),
|
|
total: result.data.total,
|
|
},
|
|
};
|
|
}
|