feat: 수동입고 기능 구현 및 입고일 날짜 포맷 버그 수정

- 수동입고 API (POST /api/purchase-receipts/manual) 추가
- 수동입고 모달 UI 구현 (도매상 선택, 품목 동적 추가, 금액 자동계산)
- 도매상 등록 모달 z-index 처리 (수동입고 모달 위에 표시)
- Excel 입고 시 receipt_date 튜플/대시 없는 날짜 포맷 정규화
- inventory_lots에 lot_number, expiry_date 저장 누락 수정
- CLAUDE.md 추가 (lot_id vs lot_number 구분 가이드)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-18 07:34:56 +00:00
parent 3d13c0b1f3
commit 3a39951fdc
4 changed files with 471 additions and 1 deletions

8
CLAUDE.md Normal file
View File

@ -0,0 +1,8 @@
# CLAUDE.md - AI 개발 가이드
## DB 주의사항
### inventory_lots 테이블: lot_id vs lot_number
- `lot_id` (INTEGER PK): 시스템이 자동 생성하는 내부 식별자. 재고 추적/조제/원장 등 모든 로직에서 로트를 참조할 때 사용.
- `lot_number` (TEXT, nullable): 도매상이 부여한 납품 로트번호. 사용자가 직접 입력하는 참고용 텍스트.
- INSERT 시 `lot_number``expiry_date`를 빠뜨리지 말 것. 둘 다 nullable이지만 사용자가 입력했으면 반드시 저장해야 함.

174
app.py
View File

@ -168,6 +168,40 @@ def create_patient():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<int:patient_id>', methods=['PUT'])
def update_patient(patient_id):
"""환자 정보 수정"""
try:
data = request.json
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT patient_id FROM patients WHERE patient_id = ?", (patient_id,))
if not cursor.fetchone():
return jsonify({'success': False, 'error': '환자를 찾을 수 없습니다'}), 404
cursor.execute("""
UPDATE patients
SET name = ?, phone = ?, jumin_no = ?, gender = ?,
birth_date = ?, address = ?, notes = ?
WHERE patient_id = ?
""", (
data.get('name'),
data.get('phone'),
data.get('jumin_no'),
data.get('gender'),
data.get('birth_date'),
data.get('address'),
data.get('notes'),
patient_id
))
return jsonify({
'success': True,
'message': '환자 정보가 수정되었습니다'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 약재 관리 API ====================
@app.route('/api/herbs', methods=['GET'])
@ -710,6 +744,11 @@ def upload_purchase_excel():
# receipt_date를 문자열로 확실히 변환
receipt_date_str = str(receipt_date)
# YYYY-MM-DD 포맷으로 정규화
clean_date = receipt_date_str.replace('-', '')
if len(clean_date) == 8 and clean_date.isdigit():
receipt_date_str = f"{clean_date[:4]}-{clean_date[4:6]}-{clean_date[6:8]}"
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
date_str = receipt_date_str.replace('-', '')
@ -833,7 +872,7 @@ def upload_purchase_excel():
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
unit_price_per_g, quantity_received, quantity_onhand)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (herb_item_id, supplier_id, line_id, str(receipt_date),
""", (herb_item_id, supplier_id, line_id, receipt_date_str,
row.get('origin_country'), unit_price, quantity, quantity))
lot_id = cursor.lastrowid
@ -870,6 +909,139 @@ def upload_purchase_excel():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 수동 입고 API ====================
@app.route('/api/purchase-receipts/manual', methods=['POST'])
def create_manual_receipt():
"""수동 입고 처리"""
try:
data = request.get_json()
# 필수값 검증
supplier_id = data.get('supplier_id')
receipt_date = data.get('receipt_date')
notes = data.get('notes', '')
lines = data.get('lines', [])
if not supplier_id:
return jsonify({'success': False, 'error': '도매상을 선택해주세요.'}), 400
if not receipt_date:
return jsonify({'success': False, 'error': '입고일을 입력해주세요.'}), 400
if not lines or len(lines) == 0:
return jsonify({'success': False, 'error': '입고 품목을 1개 이상 추가해주세요.'}), 400
with get_db() as conn:
cursor = conn.cursor()
# 도매상 존재 확인
cursor.execute("SELECT name FROM suppliers WHERE supplier_id = ?", (supplier_id,))
supplier_info = cursor.fetchone()
if not supplier_info:
return jsonify({'success': False, 'error': '유효하지 않은 도매상입니다.'}), 400
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
date_str = receipt_date.replace('-', '')
cursor.execute("""
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
FROM purchase_receipts
WHERE receipt_no LIKE ?
""", (f'PR-{date_str}-%',))
max_num = cursor.fetchone()[0]
next_num = (max_num or 0) + 1
receipt_no = f"PR-{date_str}-{next_num:04d}"
# 총 금액 계산
total_amount = sum(
float(line.get('quantity_g', 0)) * float(line.get('unit_price_per_g', 0))
for line in lines
)
# 입고장 헤더 생성
cursor.execute("""
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file, notes)
VALUES (?, ?, ?, ?, 'MANUAL', ?)
""", (supplier_id, receipt_date, receipt_no, total_amount, notes))
receipt_id = cursor.lastrowid
processed_count = 0
for line in lines:
ingredient_code = line.get('ingredient_code')
quantity_g = float(line.get('quantity_g', 0))
unit_price = float(line.get('unit_price_per_g', 0))
origin_country = line.get('origin_country', '')
lot_number = line.get('lot_number', '')
expiry_date = line.get('expiry_date', '')
line_total = quantity_g * unit_price
if not ingredient_code or quantity_g <= 0:
continue
# herb_items에서 해당 ingredient_code 조회
cursor.execute("""
SELECT herb_item_id FROM herb_items
WHERE ingredient_code = ?
""", (ingredient_code,))
herb = cursor.fetchone()
if not herb:
# herb_masters에서 약재명 가져와서 herb_items 생성
cursor.execute("""
SELECT herb_name FROM herb_masters
WHERE ingredient_code = ?
""", (ingredient_code,))
master = cursor.fetchone()
herb_name = master[0] if master else ingredient_code
cursor.execute("""
INSERT INTO herb_items (ingredient_code, herb_name)
VALUES (?, ?)
""", (ingredient_code, herb_name))
herb_item_id = cursor.lastrowid
else:
herb_item_id = herb[0]
# 입고장 라인 생성
cursor.execute("""
INSERT INTO purchase_receipt_lines
(receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total)
VALUES (?, ?, ?, ?, ?, ?)
""", (receipt_id, herb_item_id, origin_country, quantity_g, unit_price, line_total))
line_id = cursor.lastrowid
# 재고 로트 생성
cursor.execute("""
INSERT INTO inventory_lots
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
unit_price_per_g, quantity_received, quantity_onhand, lot_number, expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (herb_item_id, supplier_id, line_id, receipt_date,
origin_country, unit_price, quantity_g, quantity_g,
lot_number or None, expiry_date or None))
lot_id = cursor.lastrowid
# 재고 원장 기록
cursor.execute("""
INSERT INTO stock_ledger
(event_type, herb_item_id, lot_id, quantity_delta, unit_cost_per_g,
reference_table, reference_id)
VALUES ('RECEIPT', ?, ?, ?, ?, 'purchase_receipts', ?)
""", (herb_item_id, lot_id, quantity_g, unit_price, receipt_id))
processed_count += 1
return jsonify({
'success': True,
'message': '수동 입고가 완료되었습니다.',
'receipt_no': receipt_no,
'summary': {
'item_count': processed_count,
'total_amount': f"{total_amount:,.0f}"
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 입고장 조회/관리 API ====================
@app.route('/api/purchase-receipts', methods=['GET'])

View File

@ -2028,6 +2028,13 @@ $(document).ready(function() {
response.data.forEach(supplier => {
filterSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
});
// 수동 입고용 셀렉트 박스도 업데이트
const manualSelect = $('#manualReceiptSupplier');
manualSelect.empty().append('<option value="">도매상을 선택하세요</option>');
response.data.forEach(supplier => {
manualSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
});
}
});
}
@ -2066,6 +2073,211 @@ $(document).ready(function() {
});
});
// ==================== 수동 입고 ====================
// 전체 약재 목록 로드 (입고용 - 재고 필터 없음)
function loadAllHerbsForSelect(selectElement) {
$.get('/api/herbs/masters', function(response) {
if (response.success) {
selectElement.empty().append('<option value="">약재 선택</option>');
response.data.forEach(herb => {
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>`);
});
}
});
}
let manualReceiptLineCount = 0;
function addManualReceiptLine() {
manualReceiptLineCount++;
const row = `
<tr data-row="${manualReceiptLineCount}">
<td>
<select class="form-control form-control-sm manual-herb-select">
<option value="">약재 선택</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm manual-qty-input text-end"
min="0.1" step="0.1" placeholder="0.0">
</td>
<td>
<input type="number" class="form-control form-control-sm manual-price-input text-end"
min="0" step="0.1" placeholder="0.0">
</td>
<td class="text-end manual-line-total">0</td>
<td>
<input type="text" class="form-control form-control-sm manual-origin-input" placeholder="예: 중국">
</td>
<td>
<input type="text" class="form-control form-control-sm manual-lot-input" placeholder="로트번호">
</td>
<td>
<input type="date" class="form-control form-control-sm manual-expiry-input">
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-line">
<i class="bi bi-x"></i>
</button>
</td>
</tr>`;
$('#manualReceiptLines').append(row);
const newRow = $(`#manualReceiptLines tr[data-row="${manualReceiptLineCount}"]`);
loadAllHerbsForSelect(newRow.find('.manual-herb-select'));
// 금액 자동 계산
newRow.find('.manual-qty-input, .manual-price-input').on('input', function() {
updateManualReceiptLineTotals();
});
// 삭제 버튼
newRow.find('.remove-manual-line').on('click', function() {
$(this).closest('tr').remove();
updateManualReceiptLineTotals();
});
}
function updateManualReceiptLineTotals() {
let totalQty = 0;
let totalAmount = 0;
$('#manualReceiptLines tr').each(function() {
const qty = parseFloat($(this).find('.manual-qty-input').val()) || 0;
const price = parseFloat($(this).find('.manual-price-input').val()) || 0;
const lineTotal = qty * price;
$(this).find('.manual-line-total').text(lineTotal.toLocaleString('ko-KR'));
totalQty += qty;
totalAmount += lineTotal;
});
$('#manualReceiptTotalQty').text(totalQty.toLocaleString('ko-KR'));
$('#manualReceiptTotalAmount').text(totalAmount.toLocaleString('ko-KR'));
}
// 모달 열릴 때 초기화
$('#manualReceiptModal').on('show.bs.modal', function() {
const today = new Date().toISOString().split('T')[0];
$('#manualReceiptDate').val(today);
$('#manualReceiptSupplier').val('');
$('#manualReceiptNotes').val('');
$('#manualReceiptLines').empty();
manualReceiptLineCount = 0;
updateManualReceiptLineTotals();
addManualReceiptLine();
});
// 품목 추가 버튼
$('#addManualReceiptLineBtn').on('click', function() {
addManualReceiptLine();
});
// 새 도매상 등록 버튼 (수동 입고 모달에서)
$('#manualReceiptAddSupplierBtn').on('click', function() {
$('#supplierModal').modal('show');
});
// 도매상 모달이 수동입고 모달 위에 뜨도록 z-index 조정
$('#supplierModal').on('shown.bs.modal', function() {
if ($('#manualReceiptModal').hasClass('show')) {
$(this).css('z-index', 1060);
$('.modal-backdrop').last().css('z-index', 1055);
}
});
$('#supplierModal').on('hidden.bs.modal', function() {
$(this).css('z-index', '');
});
// 입고 저장
$('#saveManualReceiptBtn').on('click', function() {
const supplierId = $('#manualReceiptSupplier').val();
const receiptDate = $('#manualReceiptDate').val();
const notes = $('#manualReceiptNotes').val();
if (!supplierId) {
alert('도매상을 선택해주세요.');
return;
}
if (!receiptDate) {
alert('입고일을 입력해주세요.');
return;
}
const lines = [];
let valid = true;
$('#manualReceiptLines tr').each(function() {
const ingredientCode = $(this).find('.manual-herb-select').val();
const qty = parseFloat($(this).find('.manual-qty-input').val()) || 0;
const price = parseFloat($(this).find('.manual-price-input').val()) || 0;
if (!ingredientCode) {
valid = false;
alert('약재를 선택해주세요.');
return false;
}
if (qty <= 0) {
valid = false;
alert('수량을 입력해주세요.');
return false;
}
if (price <= 0) {
valid = false;
alert('단가를 입력해주세요.');
return false;
}
lines.push({
ingredient_code: ingredientCode,
quantity_g: qty,
unit_price_per_g: price,
origin_country: $(this).find('.manual-origin-input').val(),
lot_number: $(this).find('.manual-lot-input').val(),
expiry_date: $(this).find('.manual-expiry-input').val()
});
});
if (!valid) return;
if (lines.length === 0) {
alert('입고 품목을 1개 이상 추가해주세요.');
return;
}
const btn = $(this);
btn.prop('disabled', true).text('저장 중...');
$.ajax({
url: '/api/purchase-receipts/manual',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
supplier_id: supplierId,
receipt_date: receiptDate,
notes: notes,
lines: lines
}),
success: function(response) {
if (response.success) {
alert(`수동 입고 완료!\n입고번호: ${response.receipt_no}\n품목 수: ${response.summary.item_count}\n총 금액: ${response.summary.total_amount}`);
$('#manualReceiptModal').modal('hide');
loadPurchaseReceipts();
} else {
alert('오류: ' + response.error);
}
},
error: function(xhr) {
const msg = xhr.responseJSON ? xhr.responseJSON.error : '서버 오류가 발생했습니다.';
alert('오류: ' + msg);
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-check-circle"></i> 입고 저장');
}
});
});
// 입고장 업로드
$('#purchaseUploadForm').on('submit', function(e) {
e.preventDefault();

View File

@ -266,6 +266,9 @@
<div id="purchase" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>입고 관리</h3>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#manualReceiptModal">
<i class="bi bi-plus-circle"></i> 수동 입고
</button>
</div>
<!-- 입고장 목록 -->
@ -1838,6 +1841,81 @@
</div>
</div>
<!-- 수동 입고 모달 -->
<div class="modal fade" id="manualReceiptModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> 수동 입고</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- 입고 헤더 정보 -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">입고일 <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="manualReceiptDate">
</div>
<div class="col-md-4">
<label class="form-label">도매상 <span class="text-danger">*</span></label>
<div class="input-group">
<select class="form-control" id="manualReceiptSupplier">
<option value="">도매상을 선택하세요</option>
</select>
<button class="btn btn-outline-secondary" type="button" id="manualReceiptAddSupplierBtn" title="새 도매상 등록">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label">비고</label>
<input type="text" class="form-control" id="manualReceiptNotes" placeholder="비고 입력">
</div>
</div>
<!-- 품목 테이블 -->
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="table-light">
<tr>
<th style="width:25%">약재명 <span class="text-danger">*</span></th>
<th style="width:10%">수량(g) <span class="text-danger">*</span></th>
<th style="width:12%">g당 단가 <span class="text-danger">*</span></th>
<th style="width:12%">금액</th>
<th style="width:10%">원산지</th>
<th style="width:12%">로트번호</th>
<th style="width:12%">유효기한</th>
<th style="width:5%"></th>
</tr>
</thead>
<tbody id="manualReceiptLines">
<!-- 동적 행 추가 -->
</tbody>
<tfoot>
<tr class="table-warning fw-bold">
<td>합계</td>
<td id="manualReceiptTotalQty" class="text-end">0</td>
<td></td>
<td id="manualReceiptTotalAmount" class="text-end">0</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="addManualReceiptLineBtn">
<i class="bi bi-plus"></i> 품목 추가
</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-success" id="saveManualReceiptBtn">
<i class="bi bi-check-circle"></i> 입고 저장
</button>
</div>
</div>
</div>
</div>
<!-- 로트 배분 모달 -->
<div class="modal fade" id="lotAllocationModal" tabindex="-1">
<div class="modal-dialog modal-lg">