Files
Hyungi Ahn cf75462380 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>
2026-04-01 09:21:20 +09:00

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 };