- 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>
157 lines
4.1 KiB
JavaScript
157 lines
4.1 KiB
JavaScript
/**
|
|
* 한국어 스마트 검색 유틸리티
|
|
* - 초성 추출 및 매칭
|
|
* - 별칭(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 };
|