feat(purchase): 소모품 신청 시스템 v2 — 모바일 최적화, 스마트 검색, 그룹화, 입고 알림
- 4단계 상태 플로우: pending → grouped → purchased → received - 한국어 스마트 검색: 초성 매칭(ㅁㅈㄱ→면장갑), 별칭 테이블, 인메모리 캐시 - 모바일 전용 신청 페이지: 바텀시트 UI, FAB, 카드 리스트, 스크롤 페이지네이션 - 인라인 품목 등록: 미등록 품목 검색→등록→신청 단일 트랜잭션 - 관리자 그룹화: 체크박스 다중 선택, 구매 그룹(batch) 생성/일괄 구매/입고 - 입고 처리: 사진+보관위치 등록, 부분 입고 허용, batch 자동 상태 전환 - 알림: notifyHelper에 target_user_ids 추가, 구매진행중/입고완료 시 신청자 ntfy+push Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
156
system1-factory/api/utils/koreanSearch.js
Normal file
156
system1-factory/api/utils/koreanSearch.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 한국어 스마트 검색 유틸리티
|
||||
* - 초성 추출 및 매칭
|
||||
* - 별칭(alias) 매칭
|
||||
* - 인메모리 캐시 (5분 TTL)
|
||||
*/
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// 초성 목록 (19개)
|
||||
const CHOSUNG = [
|
||||
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||
];
|
||||
|
||||
// 자음 문자 집합 (초성 판별용)
|
||||
const JAMO_SET = new Set([
|
||||
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||
]);
|
||||
|
||||
// 캐시
|
||||
let cache = null;
|
||||
let cacheTime = 0;
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
/**
|
||||
* 한글 완성형 문자에서 초성 추출
|
||||
* @param {string} str
|
||||
* @returns {string} 초성 문자열
|
||||
*/
|
||||
function extractChosung(str) {
|
||||
let result = '';
|
||||
for (const ch of str) {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||
const idx = Math.floor((code - 0xAC00) / (21 * 28));
|
||||
result += CHOSUNG[idx];
|
||||
} else {
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어가 모두 자음(초성)인지 판별
|
||||
* @param {string} query
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isChosungOnly(query) {
|
||||
if (query.length < 2) return false;
|
||||
for (const ch of query) {
|
||||
if (!JAMO_SET.has(ch)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 로드 (consumable_items + item_aliases)
|
||||
*/
|
||||
async function loadCache() {
|
||||
if (cache && (Date.now() - cacheTime < CACHE_TTL)) return cache;
|
||||
|
||||
const db = await getDb();
|
||||
const [items] = await db.query(
|
||||
`SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path
|
||||
FROM consumable_items WHERE is_active = 1`
|
||||
);
|
||||
const [aliases] = await db.query(
|
||||
`SELECT alias_id, item_id, alias_name FROM item_aliases`
|
||||
);
|
||||
|
||||
// 아이템별 별칭 맵 생성
|
||||
const aliasMap = {};
|
||||
for (const a of aliases) {
|
||||
if (!aliasMap[a.item_id]) aliasMap[a.item_id] = [];
|
||||
aliasMap[a.item_id].push(a.alias_name);
|
||||
}
|
||||
|
||||
// 초성 미리 계산
|
||||
const enriched = items.map(item => ({
|
||||
...item,
|
||||
aliases: aliasMap[item.item_id] || [],
|
||||
chosung_name: extractChosung(item.item_name),
|
||||
chosung_aliases: (aliasMap[item.item_id] || []).map(a => extractChosung(a))
|
||||
}));
|
||||
|
||||
cache = enriched;
|
||||
cacheTime = Date.now();
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
function clearCache() {
|
||||
cache = null;
|
||||
cacheTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스마트 검색
|
||||
* @param {string} query - 검색어
|
||||
* @returns {Promise<Array>} 스코어 기준 상위 20건
|
||||
*/
|
||||
async function search(query) {
|
||||
if (!query || query.trim().length === 0) return [];
|
||||
const items = await loadCache();
|
||||
const q = query.trim().toLowerCase();
|
||||
const qChosung = isChosungOnly(q) ? q : null;
|
||||
|
||||
const scored = [];
|
||||
for (const item of items) {
|
||||
let score = 0;
|
||||
let matchType = '';
|
||||
|
||||
const nameLower = item.item_name.toLowerCase();
|
||||
const specLower = (item.spec || '').toLowerCase();
|
||||
const makerLower = (item.maker || '').toLowerCase();
|
||||
|
||||
// exact match (이름 완전 일치)
|
||||
if (nameLower === q) {
|
||||
score = 100; matchType = 'exact';
|
||||
}
|
||||
// substring match (이름)
|
||||
else if (nameLower.includes(q)) {
|
||||
score = 80; matchType = 'name';
|
||||
}
|
||||
// alias match
|
||||
else if (item.aliases.some(a => a.toLowerCase().includes(q))) {
|
||||
score = 75; matchType = 'alias';
|
||||
}
|
||||
// spec/maker match
|
||||
else if (specLower.includes(q) || makerLower.includes(q)) {
|
||||
score = 70; matchType = 'spec';
|
||||
}
|
||||
// 초성 매칭 (이름)
|
||||
else if (qChosung && item.chosung_name.includes(qChosung)) {
|
||||
score = 50; matchType = 'chosung';
|
||||
}
|
||||
// 초성 매칭 (별칭)
|
||||
else if (qChosung && item.chosung_aliases.some(ca => ca.includes(qChosung))) {
|
||||
score = 40; matchType = 'chosung_alias';
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
scored.push({ ...item, _score: score, _matchType: matchType });
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 높은 순, 같은 점수면 이름 짧은 순 (더 구체적)
|
||||
scored.sort((a, b) => b._score - a._score || a.item_name.length - b.item_name.length);
|
||||
return scored.slice(0, 20);
|
||||
}
|
||||
|
||||
module.exports = { search, clearCache, extractChosung, isChosungOnly };
|
||||
Reference in New Issue
Block a user