diff --git a/system1-factory/api/controllers/purchaseRequestController.js b/system1-factory/api/controllers/purchaseRequestController.js index 0a7657b..2769f90 100644 --- a/system1-factory/api/controllers/purchaseRequestController.js +++ b/system1-factory/api/controllers/purchaseRequestController.js @@ -180,6 +180,87 @@ const PurchaseRequestController = { } }, + // 일괄 신청 (장바구니, 트랜잭션) + bulkCreate: async (req, res) => { + const { getDb } = require('../dbPool'); + let conn; + try { + const { items, photo } = req.body; + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' }); + } + for (const item of items) { + if (!item.item_id && !item.item_name) { + return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' }); + } + if (!item.quantity || item.quantity < 1) { + return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' }); + } + } + + // 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가) + let photo_path = null; + if (photo) { + photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests'); + } + + const db = await getDb(); + conn = await db.getConnection(); + await conn.beginTransaction(); + + const createdIds = []; + let newItemRegistered = false; + + for (const item of items) { + let itemId = item.item_id || null; + + // 신규 품목 등록 (is_new) + if (item.is_new && item.item_name) { + const [existing] = await conn.query( + `SELECT item_id FROM consumable_items + WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL)) + AND (maker = ? OR (maker IS NULL AND ? IS NULL))`, + [item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null] + ); + if (existing.length > 0) { + itemId = existing[0].item_id; + } else { + const [ins] = await conn.query( + `INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`, + [item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable'] + ); + itemId = ins.insertId; + newItemRegistered = true; + } + } + + // purchase_request 생성 + const [result] = await conn.query( + `INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path) + VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`, + [itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null, + item.quantity, req.user.id, item.notes || null, photo_path] + ); + createdIds.push(result.insertId); + } + + await conn.commit(); + if (newItemRegistered) koreanSearch.clearCache(); + + res.status(201).json({ + success: true, + data: { request_ids: createdIds, count: createdIds.length }, + message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.` + }); + } catch (err) { + if (conn) await conn.rollback().catch(() => {}); + logger.error('bulkCreate error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } finally { + if (conn) conn.release(); + } + }, + // 스마트 검색 (초성 + 별칭 + substring) search: async (req, res) => { try { diff --git a/system1-factory/api/routes/purchaseRequestRoutes.js b/system1-factory/api/routes/purchaseRequestRoutes.js index 4f11a54..c98dcb8 100644 --- a/system1-factory/api/routes/purchaseRequestRoutes.js +++ b/system1-factory/api/routes/purchaseRequestRoutes.js @@ -15,6 +15,8 @@ router.get('/my-requests', ctrl.getMyRequests); // 품목 등록 + 신청 동시 (트랜잭션) router.post('/register-and-request', ctrl.registerAndRequest); +// 일괄 신청 (장바구니) +router.post('/bulk', ctrl.bulkCreate); // 구매신청 CRUD router.get('/', ctrl.getAll); diff --git a/system1-factory/web/css/purchase-mobile.css b/system1-factory/web/css/purchase-mobile.css index 6298360..506e37b 100644 --- a/system1-factory/web/css/purchase-mobile.css +++ b/system1-factory/web/css/purchase-mobile.css @@ -215,6 +215,75 @@ } .pm-search-register:active { background: #fff7ed; } +/* 장바구니 */ +.pm-cart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.pm-cart-title { font-size: 14px; font-weight: 600; color: #374151; } +.pm-cart-count { font-size: 12px; color: #ea580c; font-weight: 600; } +.pm-cart-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 8px; + margin-bottom: 6px; +} +.pm-cart-item-info { flex: 1; min-width: 0; } +.pm-cart-item-name { font-size: 13px; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pm-cart-item-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; } +.pm-cart-item-new { font-size: 10px; color: #ea580c; } +.pm-cart-qty { + width: 48px; + padding: 4px 6px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 14px; + text-align: center; + flex-shrink: 0; +} +.pm-cart-memo { + width: 80px; + padding: 4px 6px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 12px; + flex-shrink: 0; +} +.pm-cart-remove { + width: 24px; + height: 24px; + border: none; + background: #fecaca; + color: #dc2626; + border-radius: 50%; + font-size: 14px; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} +/* 신규 품목 인라인 필드 */ +.pm-cart-new-fields { + display: flex; + gap: 4px; + margin-top: 4px; +} +.pm-cart-new-fields input, .pm-cart-new-fields select { + padding: 3px 6px; + border: 1px solid #e5e7eb; + border-radius: 4px; + font-size: 11px; + width: 100%; +} + /* 폼 필드 */ .pm-field { margin-bottom: 12px; } .pm-label { display: block; font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; } diff --git a/system1-factory/web/pages/purchase/request-mobile.html b/system1-factory/web/pages/purchase/request-mobile.html index df1047a..42d02ea 100644 --- a/system1-factory/web/pages/purchase/request-mobile.html +++ b/system1-factory/web/pages/purchase/request-mobile.html @@ -6,8 +6,8 @@