From a8674e6b91fb4d356c311fad0251878de154da53 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 24 Nov 2025 11:16:32 +0000 Subject: (최겸) 구매 입찰 수정(폐찰, 낙찰 결재 기능 추가 등) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/bidding/ProjectSelectorBid.tsx | 27 +- .../bidding/create/bidding-create-dialog.tsx | 8 +- .../bidding/manage/bidding-basic-info-editor.tsx | 22 +- .../bidding/manage/bidding-companies-editor.tsx | 27 +- .../manage/bidding-detail-vendor-create-dialog.tsx | 30 +- components/bidding/manage/bidding-items-editor.tsx | 271 ++++++- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 62 +- components/bidding/price-adjustment-dialog.tsx | 23 +- .../selectors/cost-center/cost-center-selector.tsx | 26 - .../selectors/cost-center/cost-center-service.ts | 40 +- .../cost-center/cost-center-single-selector.tsx | 24 +- .../selectors/gl-account/gl-account-selector.tsx | 20 +- .../selectors/gl-account/gl-account-service.ts | 32 +- .../gl-account/gl-account-single-selector.tsx | 14 +- .../procurement-item-selector-dialog-single.tsx | 2 +- .../selectors/wbs-code/wbs-code-selector.tsx | 37 +- .../common/selectors/wbs-code/wbs-code-service.ts | 48 +- .../wbs-code/wbs-code-single-selector.tsx | 40 +- db/schema/bidding.ts | 4 - lib/approval/handlers-registry.ts | 10 + ...\230 \354\232\224\354\262\255\354\204\234.html" | 788 +++++++++++++++++++++ ...\230 \354\232\224\354\262\255\354\204\234.html" | 581 +++++++++++++++ lib/bidding/approval-actions.ts | 325 ++++++++- lib/bidding/detail/bidding-actions.ts | 160 ++--- lib/bidding/detail/service.ts | 58 +- lib/bidding/detail/table/bidding-award-dialog.tsx | 190 ++++- .../detail/table/bidding-detail-vendor-table.tsx | 77 ++ lib/bidding/failure/biddings-closure-dialog.tsx | 77 +- lib/bidding/failure/biddings-failure-table.tsx | 81 ++- lib/bidding/handlers.ts | 429 +++++++++++ lib/bidding/list/bidding-pr-documents-dialog.tsx | 2 +- lib/bidding/list/create-bidding-dialog.tsx | 64 +- lib/bidding/service.ts | 69 +- lib/bidding/validation.ts | 2 +- .../vendor/components/pr-items-pricing-table.tsx | 8 +- lib/bidding/vendor/partners-bidding-detail.tsx | 27 +- .../vendor/partners-bidding-list-columns.tsx | 13 + lib/rfq-last/quotation-compare-view.tsx | 2 - .../editor/commercial-terms-form.tsx | 23 +- lib/rfq-last/vendor/price-adjustment-dialog.tsx | 23 +- .../table/detail-table/rfq-detail-table.tsx | 10 +- .../tech-sales-rfq-attachments-sheet-copy-1118.tsx | 710 ------------------- .../bid-history-table-columns.tsx | 2 - .../bid-history-table/bid-history-table.tsx | 2 - 44 files changed, 3274 insertions(+), 1216 deletions(-) create mode 100644 "lib/approval/templates/\354\236\205\354\260\260 \352\262\260\352\263\274 \354\227\205\354\262\264 \354\204\240\354\240\225 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" create mode 100644 "lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" delete mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx index de9e435e..0fc567b3 100644 --- a/components/bidding/ProjectSelectorBid.tsx +++ b/components/bidding/ProjectSelectorBid.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Check, ChevronsUpDown, X } from "lucide-react" +import { Check, ChevronsUpDown } from "lucide-react" import { Button } from "@/components/ui/button" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" @@ -10,7 +10,7 @@ import { getProjects, type Project } from "@/lib/rfqs/service" interface ProjectSelectorProps { selectedProjectId?: number | null; - onProjectSelect: (project: Project) => void; + onProjectSelect: (project: Project | null) => void; placeholder?: string; filterType?: string; // 옵션으로 필터 타입 지정 가능 } @@ -76,7 +76,7 @@ export function ProjectSelector({ // 이미 선택된 프로젝트를 다시 선택하면 선택 해제 if (selectedProject?.id === project.id) { setSelectedProject(null); - onProjectSelect(null as any); // 선택 해제를 위해 null 전달 + onProjectSelect(null); // 선택 해제를 위해 null 전달 setOpen(false); return; } @@ -86,12 +86,6 @@ export function ProjectSelector({ setOpen(false); }; - // 프로젝트 선택 해제 - const handleClearSelection = (e: React.MouseEvent) => { - e.stopPropagation(); // Popover가 열리지 않도록 방지 - setSelectedProject(null); - onProjectSelect(null as any); // 선택 해제를 위해 null 전달 - }; return ( @@ -106,20 +100,7 @@ export function ProjectSelector({ {isLoading ? ( "프로젝트 로딩 중..." ) : selectedProject ? ( -
- {selectedProject.projectCode} -
- - -
-
+ {selectedProject.projectCode} ) : (
{placeholder} diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index 9b0a6f66..498d8d1c 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -618,7 +618,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp {/* 4행: 하도급법적용여부, SHI 지급조건 */}
- {/* ( @@ -637,13 +637,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }} /> - 연동제 적용 요건 + 하도급법 적용여부
)} - /> */} + /> - 구매조직 + 구매조직 * @@ -1019,7 +1019,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
- {/* 4행: 계약 납품일, 연동제 적용 가능 */} + {/* 4행: 계약 납품일, 하도급법 적용여부 */}
계약 납품일 @@ -1034,6 +1034,24 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB }} />
+
+ 하도급법 적용여부 +
+ { + setBiddingConditions(prev => ({ + ...prev, + isPriceAdjustmentApplicable: checked + })) + }} + id="price-adjustment" + /> + + {biddingConditions.isPriceAdjustmentApplicable ? "적용" : "미적용"} + +
+
{/* 5행: 스페어파트 옵션 */} diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index a5ce1349..da566c82 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -49,6 +49,7 @@ interface QuotationVendor { currency: string invitationStatus: string isPriceAdjustmentApplicableQuestion?: boolean + businessSize?: string | null } interface BiddingCompaniesEditorProps { @@ -106,17 +107,18 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC try { const result = await getBiddingVendors(biddingId) if (result.success && result.data) { - const vendorsList = result.data.map(v => ({ - ...v, - companyId: v.companyId || undefined, - vendorName: v.vendorName || '', - vendorCode: v.vendorCode || '', - contactPerson: v.contactPerson ?? undefined, - contactEmail: v.contactEmail ?? undefined, - contactPhone: v.contactPhone ?? undefined, - quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, - isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, - })) + const vendorsList = result.data.map(v => ({ + ...v, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson ?? undefined, + contactEmail: v.contactEmail ?? undefined, + contactPhone: v.contactPhone ?? undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + businessSize: v.businessSize ?? undefined, + })) setVendors(vendorsList) // 각 업체별 첫 번째 담당자 정보 로드 @@ -161,6 +163,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC currency: v.currency || 'KRW', invitationStatus: v.invitationStatus, isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + businessSize: v.businessSize ?? undefined, })) setVendors(vendorsList) @@ -503,6 +506,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC 선택 업체명 업체코드 + 기업규모 담당자 이름 담당자 이메일 담당자 연락처 @@ -526,6 +530,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC {vendor.vendorName} {vendor.vendorCode} + {vendor.businessSize || '-'} {vendor.companyId && vendorFirstContacts.has(vendor.companyId) ? vendorFirstContacts.get(vendor.companyId)!.contactName diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index de813121..0dd9f0eb 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -28,7 +28,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react' import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' +import { searchVendorsForBidding, getVendorsBusinessSize } from '@/lib/bidding/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' import { Badge } from '@/components/ui/badge' @@ -68,6 +68,9 @@ export function BiddingDetailVendorCreateDialog({ const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState([]) const [vendorOpen, setVendorOpen] = React.useState(false) + // Business size 정보 캐싱 + const [businessSizeMap, setBusinessSizeMap] = React.useState>({}) + // 벤더 로드 const loadVendors = React.useCallback(async () => { try { @@ -204,6 +207,28 @@ export function BiddingDetailVendorCreateDialog({ const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor) + // 선택된 vendor들의 businessSize 정보를 useMemo로 캐싱 + const loadBusinessSize = React.useMemo(() => { + const selectedVendorIds = selectedVendors.map(v => v.id) + if (selectedVendorIds.length === 0) return + + // 이미 로드된 vendor들은 제외 + const newVendorIds = selectedVendorIds.filter(id => !(id in businessSizeMap)) + + if (newVendorIds.length > 0) { + getVendorsBusinessSize(newVendorIds).then(result => { + setBusinessSizeMap(prev => ({ ...prev, ...result })) + }).catch(error => { + console.error('Failed to load business size:', error) + }) + } + }, [selectedVendors, businessSizeMap]) + + // selectedVendors가 변경될 때마다 businessSize 로드 + React.useEffect(() => { + loadBusinessSize + }, [loadBusinessSize]) + return ( @@ -382,6 +407,9 @@ export function BiddingDetailVendorCreateDialog({ > 연동제 적용요건 문의 + + 기업규모: {businessSizeMap[item.vendor.id] || '미정'} + ))} diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index f0287ae4..208cf040 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -441,8 +441,8 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: null, - purchaseUnit: '1', + priceUnit: 1, + purchaseUnit: 'EA', materialWeight: null, wbsCode: null, wbsName: null, @@ -495,8 +495,8 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems prev.map((item) => { if (item.id === id) { const updatedItem = { ...item, ...updates } - // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산 - if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) { + // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산 + if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) { updatedItem.targetAmount = calculateTargetAmount(updatedItem) } return updatedItem @@ -533,20 +533,66 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const calculateTargetAmount = (item: PRItemInfo): string => { const unitPrice = parseFloat(item.targetUnitPrice?.replace(/,/g, '') || '0') || 0 - const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1 + const priceUnit = parseFloat(item.priceUnit || '1') || 1 let amount = 0 if (quantityWeightMode === 'quantity') { const quantity = parseFloat(item.quantity || '0') || 0 - amount = (quantity / purchaseUnit) * unitPrice + amount = (quantity / priceUnit) * unitPrice } else { const weight = parseFloat(item.totalWeight || '0') || 0 - amount = (weight / purchaseUnit) * unitPrice + amount = (weight / priceUnit) * unitPrice } return Math.floor(amount).toString() } + // 합계 계산 함수들 + const calculateTotals = () => { + let quantityTotal = 0 + let weightTotal = 0 + let targetAmountTotal = 0 + let budgetAmountTotal = 0 + let actualAmountTotal = 0 + + items.forEach((item) => { + // 수량 합계 + if (item.quantity) { + quantityTotal += parseFloat(item.quantity) || 0 + } + + // 중량 합계 + if (item.totalWeight) { + weightTotal += parseFloat(item.totalWeight) || 0 + } + + // 내정금액 합계 + if (item.targetAmount) { + targetAmountTotal += parseFloat(item.targetAmount.replace(/,/g, '')) || 0 + } + + // 예산금액 합계 + if (item.budgetAmount) { + budgetAmountTotal += parseFloat(item.budgetAmount.replace(/,/g, '')) || 0 + } + + // 실적금액 합계 + if (item.actualAmount) { + actualAmountTotal += parseFloat(item.actualAmount.replace(/,/g, '')) || 0 + } + }) + + return { + quantityTotal, + weightTotal, + targetAmountTotal, + budgetAmountTotal, + actualAmountTotal, + } + } + + const totals = calculateTotals() + if (isLoading) { return (
@@ -572,14 +618,16 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 프로젝트코드 프로젝트명 - PR 번호 + 자재그룹코드 * 자재그룹명 * 자재코드 자재명 - 수량 - 단위 + 수량(중량) * + 단위 * + 가격단위 구매단위 + 자재순중량 내정단가 내정금액 내정통화 @@ -587,19 +635,120 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 예산통화 실적금액 실적통화 + 납품요청일 * WBS코드 WBS명 코스트센터코드 코스트센터명 GL계정코드 GL계정명 - 납품요청일 * + PR 번호 액션 + {/* 합계 행 */} + + + 합계 + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + + {quantityWeightMode === 'quantity' + ? `${formatNumberWithCommas(totals.quantityTotal.toString())}` + : `${formatNumberWithCommas(totals.weightTotal.toString())}` + } + + + + + {quantityWeightMode === 'quantity' + ? `${items[0]?.quantityUnit || 'EA'}` + : `${items[0]?.weightUnit || 'KG'}` + } + + + + - + + + - + + + - + + + - + + + {formatNumberWithCommas(totals.targetAmountTotal.toString())} + + + - + + + {formatNumberWithCommas(totals.budgetAmountTotal.toString())} + + + - + + + {formatNumberWithCommas(totals.actualAmountTotal.toString())} + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + {items.map((item, index) => ( @@ -617,10 +766,17 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems { - updatePRItem(item.id, { - projectId: project.id, - projectInfo: project.projectName - }) + if (project) { + updatePRItem(item.id, { + projectId: project.id, + projectInfo: project.projectName + }) + } else { + updatePRItem(item.id, { + projectId: null, + projectInfo: null + }) + } }} placeholder="프로젝트 선택" /> @@ -633,14 +789,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems className="h-8 text-xs bg-muted/50" /> - - - + {biddingType !== 'equipment' ? ( updatePRItem(item.id, { quantity: e.target.value })} className="h-8 text-xs" + required /> ) : ( updatePRItem(item.id, { totalWeight: e.target.value })} className="h-8 text-xs" + required /> )} @@ -762,6 +913,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems updatePRItem(item.id, { weightUnit: value })} + required > @@ -797,9 +950,42 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems type="number" min="1" step="1" - placeholder="구매단위" - value={item.purchaseUnit || ''} - onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })} + placeholder="가격단위" + value={item.priceUnit || ''} + onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })} + className="h-8 text-xs" + /> + + + + + + updatePRItem(item.id, { materialWeight: e.target.value })} className="h-8 text-xs" /> @@ -887,10 +1073,24 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems + + updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} + className="h-8 text-xs" + required + /> +
diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx index 3aad5787..f87b6928 100644 --- a/components/common/selectors/cost-center/cost-center-selector.tsx +++ b/components/common/selectors/cost-center/cost-center-selector.tsx @@ -77,11 +77,6 @@ export function CostCenterSelector({ const [globalFilter, setGlobalFilter] = useState('') const [isPending, startTransition] = useTransition() - // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD) - const formatDate = (dateStr: string) => { - if (!dateStr || dateStr.length !== 8) return dateStr - return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}` - } // Cost Center 선택 핸들러 const handleCodeSelect = useCallback(async (code: CostCenter) => { @@ -117,27 +112,6 @@ export function CostCenterSelector({
{row.getValue('KTEXT')}
), }, - { - accessorKey: 'LTEXT', - header: '설명', - cell: ({ row }) => ( -
{row.getValue('LTEXT')}
- ), - }, - { - accessorKey: 'DATAB', - header: '시작일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATAB'))}
- ), - }, - { - accessorKey: 'DATBI', - header: '종료일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATBI'))}
- ), - }, { id: 'actions', header: '선택', diff --git a/components/common/selectors/cost-center/cost-center-service.ts b/components/common/selectors/cost-center/cost-center-service.ts index 844215f0..1e7e43f5 100644 --- a/components/common/selectors/cost-center/cost-center-service.ts +++ b/components/common/selectors/cost-center/cost-center-service.ts @@ -5,19 +5,16 @@ import { oracleKnex } from '@/lib/oracle-db/db' // Cost Center 타입 정의 export interface CostCenter { KOSTL: string // Cost Center 코드 - DATAB: string // 시작일 - DATBI: string // 종료일 KTEXT: string // 단축 텍스트 - LTEXT: string // 긴 텍스트 } // 테스트 환경용 폴백 데이터 const FALLBACK_TEST_DATA: CostCenter[] = [ - { KOSTL: 'D6023930', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매팀', LTEXT: '구매팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' }, - { KOSTL: 'D6023931', DATAB: '20230101', DATBI: '99991231', KTEXT: '자재팀', LTEXT: '자재팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' }, - { KOSTL: 'D6023932', DATAB: '20230101', DATBI: '99991231', KTEXT: '조달팀', LTEXT: '조달팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' }, - { KOSTL: 'D6023933', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매1팀', LTEXT: '구매1팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' }, - { KOSTL: 'D6023934', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매2팀', LTEXT: '구매2팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' }, + { KOSTL: 'D6023930', KTEXT: '구매팀(테스트데이터 - 오라클 페칭 실패시)' }, + { KOSTL: 'D6023931', KTEXT: '자재팀(테스트데이터 - 오라클 페칭 실패시)' }, + { KOSTL: 'D6023932', KTEXT: '조달팀(테스트데이터 - 오라클 페칭 실패시)' }, + { KOSTL: 'D6023933', KTEXT: '구매1팀(테스트데이터 - 오라클 페칭 실패시)' }, + { KOSTL: 'D6023934', KTEXT: '구매2팀(테스트데이터 - 오라클 페칭 실패시)' }, ] /** @@ -35,19 +32,11 @@ export async function getCostCenters(): Promise<{ console.log('📋 [getCostCenters] Oracle 쿼리 시작...') const result = await oracleKnex.raw(` - SELECT - KOSTL, - DATAB, - DATBI, - KTEXT, - LTEXT - FROM CMCTB_COSTCENTER - WHERE ROWNUM < 100 - AND NVL(BKZKP,' ') = ' ' - AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI - AND KOKRS = 'H100' - ORDER BY KOSTL + SELECT KOSTL , KTEXT FROM CMCTB_COSTCENTER + WHERE TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI AND ROWNUM < 10 `) + // KOSTL -> CONTCENTER + // KTEXT -> CONTCENTER명 // Oracle raw query의 결과는 rows 배열에 들어있음 const rows = (result.rows || result) as Array> @@ -56,17 +45,12 @@ export async function getCostCenters(): Promise<{ // null 값 필터링 const cleanedResult = rows - .filter((item) => - item.KOSTL && - item.DATAB && - item.DATBI + .filter((item) => + item.KOSTL ) .map((item) => ({ KOSTL: String(item.KOSTL), - DATAB: String(item.DATAB), - DATBI: String(item.DATBI), - KTEXT: String(item.KTEXT || ''), - LTEXT: String(item.LTEXT || '') + KTEXT: String(item.KTEXT || '') })) console.log(`✅ [getCostCenters] 필터링 후 ${cleanedResult.length}건`) diff --git a/components/common/selectors/cost-center/cost-center-single-selector.tsx b/components/common/selectors/cost-center/cost-center-single-selector.tsx index e09f782b..952fab3f 100644 --- a/components/common/selectors/cost-center/cost-center-single-selector.tsx +++ b/components/common/selectors/cost-center/cost-center-single-selector.tsx @@ -133,27 +133,6 @@ export function CostCenterSingleSelector({
{row.getValue('KTEXT')}
), }, - { - accessorKey: 'LTEXT', - header: '설명', - cell: ({ row }) => ( -
{row.getValue('LTEXT')}
- ), - }, - { - accessorKey: 'DATAB', - header: '시작일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATAB'))}
- ), - }, - { - accessorKey: 'DATBI', - header: '종료일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATBI'))}
- ), - }, { id: 'actions', header: '선택', @@ -283,7 +262,6 @@ export function CostCenterSingleSelector({
[{currentSelectedCode.KOSTL}] {currentSelectedCode.KTEXT} - - {currentSelectedCode.LTEXT}
)} @@ -291,7 +269,7 @@ export function CostCenterSingleSelector({
handleSearchChange(e.target.value)} className="flex-1" diff --git a/components/common/selectors/gl-account/gl-account-selector.tsx b/components/common/selectors/gl-account/gl-account-selector.tsx index 7e47a072..63eeede8 100644 --- a/components/common/selectors/gl-account/gl-account-selector.tsx +++ b/components/common/selectors/gl-account/gl-account-selector.tsx @@ -5,9 +5,8 @@ * * @description * - 오라클에서 GL 계정들을 조회 - * - SAKNR: 계정(G/L) - * - FIPEX: 세부계정 - * - TEXT1: 계정명 + * - GL: 계정 + * - GL명: 계정명 */ import { useState, useCallback, useMemo, useTransition } from 'react' @@ -52,7 +51,6 @@ export interface GlAccountSelectorProps { export interface GlAccountItem { saknr: string // 계정(G/L) - fipex: string // 세부계정 text1: string // 계정명 displayText: string // 표시용 텍스트 } @@ -76,7 +74,7 @@ export function GlAccountSelector({ // GL 계정 선택 핸들러 const handleCodeSelect = useCallback(async (code: GlAccount) => { // 이미 선택된 계정을 다시 선택하면 선택 해제 - if (selectedCode && selectedCode.SAKNR === code.SAKNR && selectedCode.FIPEX === code.FIPEX) { + if (selectedCode && selectedCode.SAKNR === code.SAKNR) { onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달 setOpen(false) return @@ -100,13 +98,6 @@ export function GlAccountSelector({
{row.getValue('SAKNR')}
), }, - { - accessorKey: 'FIPEX', - header: '세부계정', - cell: ({ row }) => ( -
{row.getValue('FIPEX')}
- ), - }, { accessorKey: 'TEXT1', header: '계정명', @@ -206,7 +197,6 @@ export function GlAccountSelector({ {selectedCode ? (
[{selectedCode.SAKNR}] - {selectedCode.FIPEX} {selectedCode.TEXT1}
[{currentSelectedCode.SAKNR}] - {currentSelectedCode.FIPEX} - - {currentSelectedCode.TEXT1} + {currentSelectedCode.TEXT1}
)} @@ -271,7 +263,7 @@ export function GlAccountSingleSelector({
handleSearchChange(e.target.value)} className="flex-1" diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 90d4975b..84fd85ff 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -131,7 +131,7 @@ export function ProcurementItemSelectorDialogSingle({
- [{currentSelectedCode.PROJ_NO}] - {currentSelectedCode.WBS_ELMT} + [{currentSelectedCode.WBS_ELMT}] {currentSelectedCode.WBS_ELMT_NM}
@@ -278,7 +261,7 @@ export function WbsCodeSingleSelector({
handleSearchChange(e.target.value)} className="flex-1" @@ -311,8 +294,7 @@ export function WbsCodeSingleSelector({ {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { - const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT && - currentSelectedCode?.PROJ_NO === row.original.PROJ_NO + const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT return ( + + + + + + + + + + + +
+ 입찰 결과 업체 선정 품의 요청서 ({{제목}}) +
+ *결재 완료 후 낙찰이 반영되며, 협력사로 통보됩니다. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 기본 정보 +
+ 입찰명 + + {{입찰명}} + + 입찰번호 + + {{입찰번호}} +
+ 낙찰업체수 + + {{낙찰업체수}} + + 계약구분 + + {{계약구분}} +
+ P/R번호 + + {{P/R번호}} + + 예산 + + {{예산}} +
+ 내정액 + + {{내정액}} + + 입찰담당자 + + {{입찰담당자}} +
+ 입찰 개요 + + {{입찰개요}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 업체 선정 결과 +
+ 선정 사유 + + {{업체선정사유}} +
+ 순번 + + 협력사 코드 + + 협력사명 + + 기업규모 + + 연동제 희망 + + 연동제 적용 + + 낙찰 유무 + + 확정 금액 + + 내정액 + + 입찰액 + + 입찰액/내정액(%) +
1{{협력사_코드_1}}{{협력사명_1}}{{기업규모_1}}{{연동제희망여부_1}}{{연동제적용여부_1}}{{낙찰유무_1}}{{확정금액_1}}{{내정액_1}}{{입찰액_1}}{{비율_1}}
2{{협력사_코드_2}}{{협력사명_2}}{{기업규모_2}}{{연동제희망여부_2}}{{연동제적용여부_2}}{{낙찰유무_2}}{{확정금액_2}}{{내정액_2}}{{입찰액_2}}{{비율_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 품목별 입찰 정보 (총 {{대상_자재_수}} 건 - 결재본문 내 표시 품목은 10건 이하로 제한됩니다) +
+ 순번 + + 자재번호 + + 자재내역(품목명) + + 구매단위 + + 수량 + + 수량단위 + + 총중량 + + 중량단위 + + 통화 + + 내정액 + + 낙찰 협력사명 + + 입찰액 +
1{{자재번호_1}}{{자재내역_1}}{{구매단위_1}}{{수량_1}}{{수량단위_1}}{{총중량_1}}{{중량단위_1}}{{통화_1}}{{내정액_1}}{{협력사명_1}}{{입찰액_1}}
2{{자재번호_2}}{{자재내역_2}}{{구매단위_2}}{{수량_2}}{{수량단위_2}}{{총중량_2}}{{중량단위_2}}{{통화_2}}{{내정액_2}}{{협력사명_2}}{{입찰액_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 연동제 NOTE +
+ 업체명 + + 연동 합의서 + + 미연동 합의서 +
{{업체명_연동제_1}}[첨부파일 링크 또는 Y/N][첨부파일 링크 또는 Y/N]
{{업체명_연동제_2}}[첨부파일 링크 또는 Y/N][첨부파일 링크 또는 Y/N]
+ +
diff --git "a/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" "b/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" new file mode 100644 index 00000000..dafda83c --- /dev/null +++ "b/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" @@ -0,0 +1,581 @@ +
+ + + + + + + + + + + +
+ 폐찰 품의 요청서 ({{제목}}) +
+ *결재 완료 후 폐찰 처리됩니다. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 기본 정보 +
+ 입찰명 + + {{입찰명}} + + 입찰번호 + + {{입찰번호}} +
+ 낙찰업체수 + + {{낙찰업체수}} + + 계약구분 + + {{계약구분}} +
+ 내정가 + + {{내정가}} + + 입찰담당자 + + {{입찰담당자}} +
+ 입찰 개요 + + {{입찰개요}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 현황 및 폐찰 결과 +
+ 폐찰 사유 + + {{폐찰_사유}} +
+ 순번 + + 협력사 코드 + + 협력사명 + + 응찰 유무 + + 내정가 + + 입찰가 + + 입찰가/내정가(%) +
1{{협력사_코드_1}}{{협력사명_1}}{{응찰유무_1}}{{내정가_1}}{{입찰가_1}}{{비율_1}}
2{{협력사_코드_2}}{{협력사명_2}}{{응찰유무_2}}{{내정가_2}}{{입찰가_2}}{{비율_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 품목별 입찰 정보 +
+ 순번 + + 품목 코드 + + 품목명 + + 수량 + + 단위 + + 통화 + + 내정가 + + 협력사 코드 + + 협력사명 + + 입찰가 +
1{{품목코드_1}}{{품목명_1}}{{수량_1}}{{단위_1}}{{통화_1}}{{내정가_1}}{{협력사코드_1}}{{협력사명_1}}{{입찰가_1}}
2{{품목코드_2}}{{품목명_2}}{{수량_2}}{{단위_2}}{{통화_2}}{{내정가_2}}{{협력사코드_2}}{{협력사명_2}}{{입찰가_2}}
+ +
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3a82b08f..6f02e80c 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -12,7 +12,7 @@ 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; -import { mapBiddingInvitationToTemplateVariables } from './handlers'; +import { mapBiddingInvitationToTemplateVariables, mapBiddingClosureToTemplateVariables, mapBiddingAwardToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** @@ -99,7 +99,7 @@ export async function prepareBiddingApprovalData(data: { materialCode: prItemsForBidding.materialNumber, materialCodeName: prItemsForBidding.materialInfo, quantity: prItemsForBidding.quantity, - purchasingUnit: prItemsForBidding.purchaseUnit, + purchasingUnit: prItemsForBidding.priceUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, quantityUnit: prItemsForBidding.quantityUnit, totalWeight: prItemsForBidding.totalWeight, @@ -241,3 +241,324 @@ export async function requestBiddingInvitationWithApproval(data: { return result; } + +/** + * 폐찰 결재를 거쳐 입찰 폐찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingClosureWithApproval({ + * biddingId: 123, + * description: "폐찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 폐찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingClosureApprovalData(data: { + biddingId: number; + description: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingClosureToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingClosureToTemplateVariables({ + biddingId: data.biddingId, + description: data.description, + requestedAt, + }); + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingClosureWithApproval(data: { + biddingId: number; + description: string; + files?: File[]; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingClosureApproval] 폐찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + description: data.description, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingClosureApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.description.trim()) { + debugError('[BiddingClosureApproval] 폐찰 사유 없음'); + throw new Error('폐찰 사유를 입력해주세요'); + } + // 유찰상태인지 확인 + const { bidding } = await db + .select() + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (bidding.status !== 'bidding_disposal') { + debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.'); + throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'closure_pending', // 폐찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingClosureApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'closure_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding: approvalBidding, variables } = await prepareBiddingClosureApprovalData({ + biddingId: data.biddingId, + description: data.description, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingClosureApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_closure', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + description: data.description, + files: data.files, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `폐찰 - ${approvalBidding.title}`, + description: `${approvalBidding.title} 입찰 폐찰 결재`, + templateName: '폐찰 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingClosureApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingClosureApproval] 폐찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} + +/** + * 낙찰 결재를 거쳐 입찰 낙찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingAwardWithApproval({ + * biddingId: 123, + * selectionReason: "낙찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingAwardApprovalData(data: { + biddingId: number; + selectionReason: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingAwardApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingAwardApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingAwardToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + requestedAt, + }); + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingAwardWithApproval(data: { + biddingId: number; + selectionReason: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingAwardApproval] 낙찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingAwardApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.selectionReason.trim()) { + debugError('[BiddingAwardApproval] 낙찰 사유 없음'); + throw new Error('낙찰 사유를 입력해주세요'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingAwardApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'award_pending', // 낙찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingAwardApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'award_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding, variables } = await prepareBiddingAwardApprovalData({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingAwardApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_award', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `낙찰 - ${bidding.title}`, + description: `${bidding.title} 입찰 낙찰 결재`, + templateName: '입찰 결과 업체 선정 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingAwardApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingAwardApproval] 낙찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts index 70bba1c3..fb659039 100644 --- a/lib/bidding/detail/bidding-actions.ts +++ b/lib/bidding/detail/bidding-actions.ts @@ -143,85 +143,85 @@ export async function checkAllVendorsFinalSubmitted(biddingId: number) { } } -// 개찰 서버 액션 (조기개찰/개찰 구분) -export async function performBidOpening( - biddingId: number, - userId: string, - isEarly: boolean = false // 조기개찰 여부 -) { - try { - const userName = await getUserNameById(userId) +// // 개찰 서버 액션 (조기개찰/개찰 구분) +// export async function performBidOpening( +// biddingId: number, +// userId: string, +// isEarly: boolean = false // 조기개찰 여부 +// ) { +// try { +// const userName = await getUserNameById(userId) - return await db.transaction(async (tx) => { - // 1. 입찰 정보 조회 - const [bidding] = await tx - .select({ - id: biddings.id, - status: biddings.status, - submissionEndDate: biddings.submissionEndDate, - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!bidding) { - return { - success: false, - error: '입찰 정보를 찾을 수 없습니다.' - } - } - - // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만) - if (bidding.status !== 'evaluation_of_bidding') { - return { - success: false, - error: '입찰평가중 상태에서만 개찰할 수 있습니다.' - } - } - - // 3. 모든 벤더가 최종제출했는지 확인 - const checkResult = await checkAllVendorsFinalSubmitted(biddingId) - if (!checkResult.allSubmitted) { - return { - success: false, - error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})` - } - } - - // 4. 조기개찰 여부 결정 - const now = new Date() - const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null - const isBeforeDeadline = submissionEndDate && now < submissionEndDate - - // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰 - const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening' - - // 5. 입찰 상태 변경 - await tx - .update(biddings) - .set({ - status: newStatus, - updatedAt: new Date() - }) - .where(eq(biddings.id, biddingId)) - - // 캐시 무효화 - revalidateTag(`bidding-${biddingId}`) - revalidateTag('bidding-detail') - revalidatePath(`/evcp/bid/${biddingId}`) - - return { - success: true, - message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`, - status: newStatus - } - }) - } catch (error) { - console.error('Failed to perform bid opening:', error) - return { - success: false, - error: error instanceof Error ? error.message : '개찰에 실패했습니다.' - } - } -} +// return await db.transaction(async (tx) => { +// // 1. 입찰 정보 조회 +// const [bidding] = await tx +// .select({ +// id: biddings.id, +// status: biddings.status, +// submissionEndDate: biddings.submissionEndDate, +// }) +// .from(biddings) +// .where(eq(biddings.id, biddingId)) +// .limit(1) + +// if (!bidding) { +// return { +// success: false, +// error: '입찰 정보를 찾을 수 없습니다.' +// } +// } + +// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만) +// if (bidding.status !== 'evaluation_of_bidding') { +// return { +// success: false, +// error: '입찰평가중 상태에서만 개찰할 수 있습니다.' +// } +// } + +// // 3. 모든 벤더가 최종제출했는지 확인 +// const checkResult = await checkAllVendorsFinalSubmitted(biddingId) +// if (!checkResult.allSubmitted) { +// return { +// success: false, +// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})` +// } +// } + +// // 4. 조기개찰 여부 결정 +// const now = new Date() +// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null +// const isBeforeDeadline = submissionEndDate && now < submissionEndDate + +// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰 +// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening' + +// // 5. 입찰 상태 변경 +// await tx +// .update(biddings) +// .set({ +// status: newStatus, +// updatedAt: new Date() +// }) +// .where(eq(biddings.id, biddingId)) + +// // 캐시 무효화 +// revalidateTag(`bidding-${biddingId}`) +// revalidateTag('bidding-detail') +// revalidatePath(`/evcp/bid/${biddingId}`) + +// return { +// success: true, +// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`, +// status: newStatus +// } +// }) +// } catch (error) { +// console.error('Failed to perform bid opening:', error) +// return { +// success: false, +// error: error instanceof Error ? error.message : '개찰에 실패했습니다.' +// } +// } +// } diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index d0f8070f..297c6f98 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1251,9 +1251,55 @@ export async function getAwardedCompanies(biddingId: number) { } } +// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 +async function updateBiddingAmounts(biddingId: number) { + try { + // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 + const amounts = await db + .select({ + totalTargetAmount: sql`COALESCE(SUM(${prItemsForBidding.targetAmount}), 0)`, + totalBudgetAmount: sql`COALESCE(SUM(${prItemsForBidding.budgetAmount}), 0)`, + totalActualAmount: sql`COALESCE(SUM(${prItemsForBidding.actualAmount}), 0)` + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + const { totalTargetAmount, totalBudgetAmount, totalActualAmount } = amounts[0] + + // bidding 테이블 업데이트 + await db + .update(biddings) + .set({ + targetPrice: totalTargetAmount, + budget: totalBudgetAmount, + finalBidPrice: totalActualAmount, + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + console.log(`Bidding ${biddingId} amounts updated: target=${totalTargetAmount}, budget=${totalBudgetAmount}, actual=${totalActualAmount}`) + } catch (error) { + console.error('Failed to update bidding amounts:', error) + throw error + } +} + // PR 품목 정보 업데이트 export async function updatePrItem(prItemId: number, input: Partial, userId: string) { try { + // 업데이트 전 biddingId 확인 + const prItem = await db + .select({ biddingId: prItemsForBidding.biddingId }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.id, prItemId)) + .limit(1) + + if (!prItem[0]?.biddingId) { + throw new Error('PR item not found or biddingId is missing') + } + + const biddingId = prItem[0].biddingId + await db .update(prItemsForBidding) .set({ @@ -1262,12 +1308,14 @@ export async function updatePrItem(prItemId: number, input: Partial void onSuccess: () => void + onApprovalPreview?: (data: { + templateName: string + variables: Record + title: string + selectionReason: string + }) => void } interface AwardedCompany { @@ -47,7 +54,8 @@ export function BiddingAwardDialog({ biddingId, open, onOpenChange, - onSuccess + onSuccess, + onApprovalPreview }: BiddingAwardDialogProps) { const { toast } = useToast() const { data: session } = useSession() @@ -106,26 +114,36 @@ const userId = session?.user?.id || '2'; return } - startTransition(async () => { - const result = await awardBidding(biddingId, selectionReason, userId) + // 결재 템플릿 변수 준비 + const { mapBiddingAwardToTemplateVariables } = await import('@/lib/bidding/handlers') - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - // 폼 초기화 - setSelectionReason('') - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', + try { + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId, + selectionReason, + requestedAt: new Date() + }) + + // 상위 컴포넌트로 결재 미리보기 데이터 전달 + if (onApprovalPreview) { + onApprovalPreview({ + templateName: '입찰 결과 업체 선정 품의 요청서', + variables, + title: `낙찰 - ${bidding?.title}`, + selectionReason }) } - }) + + onOpenChange(false) + setSelectionReason('') + } catch (error) { + console.error('낙찰 템플릿 변수 준비 실패:', error) + toast({ + title: '오류', + description: '결재 문서 준비 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } } @@ -251,11 +269,143 @@ const userId = session?.user?.id || '2'; type="submit" disabled={isPending || awardedCompanies.length === 0} > - {isPending ? '처리 중...' : '낙찰 완료'} + {isPending ? '상신 중...' : '결재 상신'} ) + + return ( + <> + + + + + + 낙찰 처리 + + + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + + + +
+
+ {/* 낙찰 업체 정보 */} + + + + + 낙찰 업체 정보 + + + + {isLoading ? ( +
+
+

낙찰 업체 정보를 불러오는 중...

+
+ ) : awardedCompanies.length > 0 ? ( +
+ + + + 업체명 + 견적금액 + 발주비율 + 발주금액 + + + + {awardedCompanies.map((company) => ( + + +
+ 낙찰 + {company.companyName} +
+
+ + {company.finalQuoteAmount.toLocaleString()}원 + + + {company.awardRatio}% + + + {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원 + +
+ ))} +
+
+ + {/* 최종입찰가 요약 */} +
+
+ + 최종입찰가 +
+ + {finalBidPrice.toLocaleString()}원 + +
+
+ ) : ( +
+ +

낙찰된 업체가 없습니다

+

+ 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요. +

+
+ )} +
+
+ + {/* 낙찰 사유 */} +
+ +