fix: 조제 관리 약재 추가 시 마스터 약재명 표시 및 2단계 선택 구조 개선

- 약재 추가 드롭다운에서 제품명 대신 마스터 약재명 표시
- /api/herbs/masters 엔드포인트 사용하여 ingredient_code 기반 약재 목록 로드
- /api/herbs/by-ingredient/<code> 엔드포인트 추가 (제품 목록 조회)
- 2단계 선택 구조: 약재(마스터) → 제품 → 원산지/롯트
- 기존 처방 약재와 새로 추가하는 약재의 테이블 구조 통일 (6칼럼)
- 원산지 선택 칼럼에 제품/원산지 드롭다운 함께 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-15 18:31:15 +00:00
parent bfc5c992de
commit a4861dc1b8
4 changed files with 253 additions and 67 deletions

34
app.py
View File

@ -255,6 +255,40 @@ def get_herb_masters():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/herbs/by-ingredient/<ingredient_code>', methods=['GET'])
def get_herbs_by_ingredient(ingredient_code):
"""특정 ingredient_code에 해당하는 제품 목록 조회"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
h.herb_item_id,
h.insurance_code,
h.herb_name,
h.specification,
CASE
WHEN h.specification LIKE '%신흥%' THEN '신흥'
WHEN h.specification LIKE '%한동%' THEN '한동'
ELSE COALESCE(h.specification, '기타')
END as company_name,
COALESCE(SUM(il.quantity_onhand), 0) as stock_quantity,
COUNT(DISTINCT il.lot_id) as lot_count,
AVG(il.unit_price_per_g) as avg_price
FROM herb_items h
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
AND il.is_depleted = 0
WHERE h.ingredient_code = ?
AND h.is_active = 1
GROUP BY h.herb_item_id
ORDER BY stock_quantity DESC, h.herb_name
""", (ingredient_code,))
products = [dict(row) for row in cursor.fetchall()]
return jsonify({'success': True, 'data': products})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 처방 관리 API ====================
@app.route('/api/formulas', methods=['GET'])

View File

@ -617,15 +617,15 @@ $(document).ready(function() {
value="${ing.grams_per_cheop}" min="0.1" step="0.1">
</td>
<td class="total-grams">${totalGrams.toFixed(1)}</td>
<td class="product-select-cell">
<select class="form-control form-control-sm product-select" ${ing.available_products.length === 0 ? 'disabled' : ''}>
${productOptions}
</select>
</td>
<td class="origin-select-cell">
<select class="form-control form-control-sm origin-select" disabled>
<option value="">제품 먼저 선택</option>
</select>
<div class="d-flex gap-1">
<select class="form-control form-control-sm product-select" style="flex: 1;" ${ing.available_products.length === 0 ? 'disabled' : ''}>
${productOptions}
</select>
<select class="form-control form-control-sm origin-select" style="flex: 1;" disabled>
<option value="">제품 먼저 선택</option>
</select>
</div>
</td>
<td class="stock-status">대기중</td>
<td>
@ -722,7 +722,7 @@ $(document).ready(function() {
// 빈 약재 행 추가 함수
function addEmptyIngredientRow() {
const newRow = $(`
<tr>
<tr data-ingredient-code="" data-herb-id="">
<td>
<select class="form-control form-control-sm herb-select-compound">
<option value="">약재 선택</option>
@ -734,9 +734,14 @@ $(document).ready(function() {
</td>
<td class="total-grams">0.0</td>
<td class="origin-select-cell">
<select class="form-control form-control-sm origin-select" disabled>
<option value="">약재 선택 표시</option>
</select>
<div class="d-flex gap-1">
<select class="form-control form-control-sm product-select" style="flex: 1;" disabled>
<option value="">약재 선택 표시</option>
</select>
<select class="form-control form-control-sm origin-select" style="flex: 1;" disabled>
<option value="">제품 선택 표시</option>
</select>
</div>
</td>
<td class="stock-status">-</td>
<td>
@ -750,18 +755,53 @@ $(document).ready(function() {
$('#compoundIngredients').append(newRow);
// 약재 목록 로드
loadHerbsForSelect(newRow.find('.herb-select-compound'));
const herbSelect = newRow.find('.herb-select-compound');
loadHerbsForSelect(herbSelect);
// 약재 선택 시 원산지 옵션 로드
// 약재(마스터) 선택 시 제품 옵션 로드
newRow.find('.herb-select-compound').on('change', function() {
const herbId = $(this).val();
if (herbId) {
const ingredientCode = $(this).val();
const herbName = $(this).find('option:selected').data('herb-name');
if (ingredientCode) {
const row = $(this).closest('tr');
row.attr('data-ingredient-code', ingredientCode);
// 제품 목록 로드
loadProductOptions(row, ingredientCode, herbName);
// 제품 선택 활성화
row.find('.product-select').prop('disabled', false);
// 원산지 선택 초기화 및 비활성화
row.find('.origin-select').empty().append('<option value="">제품 선택 후 표시</option>').prop('disabled', true);
} else {
const row = $(this).closest('tr');
row.attr('data-ingredient-code', '');
row.attr('data-herb-id', '');
row.find('.product-select').empty().append('<option value="">약재 선택 후 표시</option>').prop('disabled', true);
row.find('.origin-select').empty().append('<option value="">제품 선택 후 표시</option>').prop('disabled', true);
}
});
// 제품 선택 이벤트
newRow.find('.product-select').on('change', function() {
const herbId = $(this).val();
const row = $(this).closest('tr');
if (herbId) {
row.attr('data-herb-id', herbId);
// 원산지 선택 활성화
row.find('.origin-select').prop('disabled', false);
// 원산지 옵션 로드
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
const gramsPerCheop = parseFloat(row.find('.grams-per-cheop').val()) || 0;
const totalGrams = gramsPerCheop * cheopTotal;
loadOriginOptions(herbId, totalGrams);
} else {
row.attr('data-herb-id', '');
row.find('.origin-select').empty().append('<option value="">제품 선택 후 표시</option>').prop('disabled', true);
}
});
@ -1599,14 +1639,48 @@ $(document).ready(function() {
}
function loadHerbsForSelect(selectElement) {
$.get('/api/herbs', function(response) {
$.get('/api/herbs/masters', function(response) {
if (response.success) {
selectElement.empty().append('<option value="">약재 선택</option>');
response.data.forEach(herb => {
selectElement.append(`<option value="${herb.herb_item_id}">${herb.herb_name}</option>`);
// 재고가 있는 약재만 필터링하여 표시
const herbsWithStock = response.data.filter(herb => herb.has_stock === 1);
herbsWithStock.forEach(herb => {
// ingredient_code를 value로 사용하고, 한글명(한자명) 형식으로 표시
let displayName = herb.herb_name;
if (herb.herb_name_hanja) {
displayName += ` (${herb.herb_name_hanja})`;
}
selectElement.append(`<option value="${herb.ingredient_code}" data-herb-name="${herb.herb_name}">${displayName}</option>`);
});
}
}).fail(function(error) {
console.error('Failed to load herbs:', error);
});
}
// ingredient_code 기반으로 제품 옵션 로드
function loadProductOptions(row, ingredientCode, herbName) {
$.get(`/api/herbs/by-ingredient/${ingredientCode}`, function(response) {
if (response.success) {
const productSelect = row.find('.product-select');
productSelect.empty();
if (response.data.length === 0) {
productSelect.append('<option value="">재고 없음</option>');
productSelect.prop('disabled', true);
} else {
productSelect.append('<option value="">제품 선택</option>');
response.data.forEach(product => {
const stockInfo = product.stock_quantity > 0 ? `(재고: ${product.stock_quantity.toFixed(1)}g)` : '(재고 없음)';
productSelect.append(`<option value="${product.herb_item_id}" ${product.stock_quantity === 0 ? 'disabled' : ''}>${product.company_name} ${stockInfo}</option>`);
});
productSelect.prop('disabled', false);
}
}
}).fail(function() {
console.error(`Failed to load products for ingredient code: ${ingredientCode}`);
});
}

View File

@ -35,6 +35,14 @@ def test_compound_ginseng_selection():
time.sleep(2)
print("✓ 조제관리 화면 진입")
# 조제 입력 섹션 표시
print("\n[2-1] 조제 입력 섹션 표시...")
show_compound_entry = page.locator('#showCompoundEntry')
if show_compound_entry.count() > 0:
show_compound_entry.click()
time.sleep(1)
print("✓ 조제 입력 섹션 표시")
# 3. 현재 화면 상태 확인
print("\n[3] 화면 상태 확인...")
@ -54,10 +62,16 @@ def test_compound_ginseng_selection():
# 처방 선택 시도
print("\n[4] 처방 선택...")
# 처방 드롭다운 찾기 (유연하게)
formula_select = page.locator('select').first
# compoundFormula select 요소 찾기 (ID로 정확히)
formula_select = page.locator('#compoundFormula')
if formula_select.count() > 0:
# select가 visible 될 때까지 기다리기
try:
formula_select.wait_for(state="visible", timeout=5000)
except:
print("⚠️ 처방 선택 드롭다운이 보이지 않음")
# 옵션 확인
options = formula_select.locator('option').all()
print(f"✓ 드롭다운 옵션: {len(options)}")
@ -71,72 +85,93 @@ def test_compound_ginseng_selection():
print("✓ 쌍화탕 선택 완료")
except Exception as e:
print(f"⚠️ label로 선택 실패: {e}")
# index로 시도
formula_select.select_option(index=1)
time.sleep(3)
print("✓ 첫 번째 처방 선택 완료")
# index로 시도 (첫 번째 옵션은 보통 placeholder이므로 index=1)
try:
formula_select.select_option(index=1)
time.sleep(3)
print("✓ 첫 번째 처방 선택 완료")
except Exception as e2:
print(f"❌ 처방 선택 실패: {e2}")
else:
print("❌ 처방 드롭다운을 찾을 수 없음")
# 4. 약재 목록 확인
print("\n[4] 약재 목록 확인...")
# 5. 약재 추가 버튼 클릭
print("\n[5] 약재 추가 버튼 클릭...")
# 약재 테이블이나 목록이 나타날 때까지 대
page.wait_for_selector('table, .ingredient-list', timeout=10000)
# 약재 추가 버튼 찾
add_ingredient_btn = page.locator('#addIngredientBtn')
# 페이지 스크린샷
page.screenshot(path='/tmp/compound_screen_1.png')
print("✓ 스크린샷 저장: /tmp/compound_screen_1.png")
if add_ingredient_btn.count() > 0:
add_ingredient_btn.click()
time.sleep(1)
print("✓ 약재 추가 버튼 클릭 완료")
# 5. 인삼 항목 찾기
print("\n[5] 인삼 항목 찾기...")
# 6. 새로 추가된 행에서 약재 선택 드롭다운 확인
print("\n[6] 약재 선택 드롭다운 확인...")
# 인삼을 포함하는 행 찾기
ginseng_row = page.locator('tr:has-text("인삼"), div:has-text("인삼")').first
# 새로 추가된 행 찾기 (마지막 행)
new_row = page.locator('#compoundIngredients tr').last
if ginseng_row.count() > 0:
print("✓ 인삼 항목 발견")
# 약재 선택 드롭다운 찾기
herb_select = new_row.locator('.herb-select-compound')
# 6. 제품 선택 드롭다운 확인
print("\n[6] 제품 선택 드롭다운 확인...")
if herb_select.count() > 0:
print("✓ 약재 선택 드롭다운 발견")
# 인삼 행에서 select 요소 찾기
product_select = ginseng_row.locator('select').first
# 드롭다운 옵션 확인
time.sleep(1) # 드롭다운이 로드될 시간 확보
options = herb_select.locator('option').all()
print(f"✓ 약재 옵션: {len(options)}")
if product_select.count() > 0:
print("✓ 제품 선택 드롭다운 발견")
# 옵션 개수 확인
options = product_select.locator('option').all()
print(f"✓ 사용 가능한 제품: {len(options)}")
# 각 옵션 출력
for idx, option in enumerate(options):
# 처음 10개 옵션 출력
for idx, option in enumerate(options[:10]):
text = option.text_content()
value = option.get_attribute('value')
print(f" [{idx}] {text} (value: {value})")
# 신흥인삼 또는 세화인삼 선택 가능한지 확인
has_shinheung = any('신흥인삼' in opt.text_content() for opt in options)
has_sehwa = any('세화인삼' in opt.text_content() for opt in options)
# 마스터 약재명이 표시되는지 확인
has_master_names = False
for option in options:
text = option.text_content()
# ingredient_code 형식의 value와 한글/한자 형식의 텍스트 확인
if '(' in text and ')' in text: # 한자 포함 형식
has_master_names = True
break
if has_shinheung or has_sehwa:
print("\n✅ 인삼 제품 선택 가능!")
if has_master_names:
print("\n마스터 약재명이 드롭다운에 표시됨!")
# 첫 번째 제품 선택 시도
if len(options) > 0:
product_select.select_option(index=0)
print(f"'{options[0].text_content()}' 선택 완료")
# 인삼 선택 시도
try:
herb_select.select_option(label='인삼 (人蔘)')
print("✓ 인삼 선택 완료")
except:
# label이 정확히 일치하지 않으면 부분 매칭
for idx, option in enumerate(options):
if '인삼' in option.text_content():
herb_select.select_option(index=idx)
print(f"✓ 인삼 선택 완료 (index {idx})")
break
time.sleep(1)
# 제품 선택 드롭다운 확인
product_select = new_row.locator('.product-select')
if product_select.count() > 0:
print("\n[7] 제품 선택 드롭다운 확인...")
time.sleep(1) # 제품 목록 로드 대기
product_options = product_select.locator('option').all()
print(f"✓ 제품 옵션: {len(product_options)}")
for idx, option in enumerate(product_options):
print(f" [{idx}] {option.text_content()}")
else:
print("\n❌ 인삼 대체 제품이 드롭다운에 없음")
print("\n⚠️ 마스터 약재명 대신 제품명이 드롭다운에 표시됨")
print("(신흥생강, 신흥작약 등의 제품명이 보임)")
else:
print("❌ 제품 선택 드롭다운을 찾을 수 없음")
print("페이지 HTML 일부:")
print(ginseng_row.inner_html()[:500])
print("❌ 약재 선택 드롭다운을 찾을 수 없음")
else:
print("❌ 인삼 항목을 찾을 수 없음")
print("\n페이지 내용:")
print(page.content()[:2000])
print("❌ 약재 추가 버튼을 찾을 수 없음")
# 7. 최종 스크린샷
page.screenshot(path='/tmp/compound_screen_final.png')

43
test_herb_select.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>약재 선택 테스트</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>약재 선택 드롭다운 테스트</h1>
<div id="test-area"></div>
<button id="add-row">행 추가</button>
<script>
$('#add-row').click(function() {
// API 호출하여 약재 목록 가져오기
$.get('http://localhost:5001/api/herbs/masters', function(response) {
console.log('API Response:', response);
if (response.success) {
const select = $('<select></select>');
select.append('<option value="">약재 선택</option>');
// 재고가 있는 약재만 필터링
const herbsWithStock = response.data.filter(herb => herb.has_stock === 1);
console.log('Herbs with stock:', herbsWithStock.length);
herbsWithStock.forEach(herb => {
let displayName = herb.herb_name;
if (herb.herb_name_hanja) {
displayName += ` (${herb.herb_name_hanja})`;
}
select.append(`<option value="${herb.ingredient_code}">${displayName}</option>`);
});
$('#test-area').append(select);
$('#test-area').append('<br><br>');
}
});
});
</script>
</body>
</html>