feat: 판매내역 페이지에 제품 썸네일 이미지 표시
- app.py: /api/sales-detail에서 product_images.db 조회하여 thumbnail 반환
- admin_sales_pos.html: 거래별/목록 뷰에 36x36 썸네일 표시
- 이미지 없는 제품은 📦 플레이스홀더 표시
- barcode 우선, drug_code 폴백으로 이미지 매칭
This commit is contained in:
parent
9ce7e884d7
commit
fa4e87b461
@ -3555,7 +3555,8 @@ def api_sales_detail():
|
|||||||
'supplier': row.supplier or '',
|
'supplier': row.supplier or '',
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'unit_price': int(unit_price),
|
'unit_price': int(unit_price),
|
||||||
'total_price': int(total_price)
|
'total_price': int(total_price),
|
||||||
|
'thumbnail': None # 나중에 채워짐
|
||||||
})
|
})
|
||||||
|
|
||||||
total_amount += total_price
|
total_amount += total_price
|
||||||
@ -3563,9 +3564,57 @@ def api_sales_detail():
|
|||||||
barcode_count += 1
|
barcode_count += 1
|
||||||
unique_products.add(drug_code)
|
unique_products.add(drug_code)
|
||||||
|
|
||||||
|
# 제품 이미지 조회 (product_images.db에서)
|
||||||
|
try:
|
||||||
|
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||||||
|
if images_db_path.exists():
|
||||||
|
img_conn = sqlite3.connect(str(images_db_path))
|
||||||
|
img_cursor = img_conn.cursor()
|
||||||
|
|
||||||
|
# barcode와 drug_code 수집
|
||||||
|
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||||||
|
drug_codes = [item['drug_code'] for item in items if item['drug_code']]
|
||||||
|
|
||||||
|
# 이미지 조회 (barcode 또는 drug_code로 매칭)
|
||||||
|
image_map = {}
|
||||||
|
if barcodes:
|
||||||
|
placeholders = ','.join(['?' for _ in barcodes])
|
||||||
|
img_cursor.execute(f'''
|
||||||
|
SELECT barcode, thumbnail_base64
|
||||||
|
FROM product_images
|
||||||
|
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||||
|
''', barcodes)
|
||||||
|
for row in img_cursor.fetchall():
|
||||||
|
image_map[f'bc:{row[0]}'] = row[1]
|
||||||
|
|
||||||
|
if drug_codes:
|
||||||
|
placeholders = ','.join(['?' for _ in drug_codes])
|
||||||
|
img_cursor.execute(f'''
|
||||||
|
SELECT drug_code, thumbnail_base64
|
||||||
|
FROM product_images
|
||||||
|
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||||
|
''', drug_codes)
|
||||||
|
for row in img_cursor.fetchall():
|
||||||
|
if f'dc:{row[0]}' not in image_map: # barcode 우선
|
||||||
|
image_map[f'dc:{row[0]}'] = row[1]
|
||||||
|
|
||||||
|
img_conn.close()
|
||||||
|
|
||||||
|
# 아이템에 썸네일 매핑
|
||||||
|
for item in items:
|
||||||
|
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||||||
|
if thumb:
|
||||||
|
item['thumbnail'] = thumb
|
||||||
|
except Exception as img_err:
|
||||||
|
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||||||
|
|
||||||
# 바코드 매핑률 계산
|
# 바코드 매핑률 계산
|
||||||
barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0
|
barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0
|
||||||
|
|
||||||
|
# 이미지 매핑률 계산
|
||||||
|
image_count = sum(1 for item in items if item.get('thumbnail'))
|
||||||
|
image_rate = round(image_count / len(items) * 100, 1) if items else 0
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'items': items[:500], # 최대 500건
|
'items': items[:500], # 최대 500건
|
||||||
@ -3573,6 +3622,7 @@ def api_sales_detail():
|
|||||||
'total_count': len(items),
|
'total_count': len(items),
|
||||||
'total_amount': int(total_amount),
|
'total_amount': int(total_amount),
|
||||||
'barcode_rate': barcode_rate,
|
'barcode_rate': barcode_rate,
|
||||||
|
'image_rate': image_rate,
|
||||||
'unique_products': len(unique_products)
|
'unique_products': len(unique_products)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -369,13 +369,42 @@
|
|||||||
|
|
||||||
/* 제품 셀 */
|
/* 제품 셀 */
|
||||||
.product-cell {
|
.product-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.product-thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.product-thumb-placeholder {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.product-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.product-name {
|
.product-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.product-supplier {
|
.product-supplier {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -796,8 +825,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="product-cell">
|
<div class="product-cell">
|
||||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
${item.thumbnail
|
||||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" alt="">`
|
||||||
|
: `<div class="product-thumb-placeholder">📦</div>`
|
||||||
|
}
|
||||||
|
<div class="product-info">
|
||||||
|
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||||
|
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${renderCode(item)}</td>
|
<td>${renderCode(item)}</td>
|
||||||
@ -826,8 +861,14 @@
|
|||||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="product-cell">
|
<div class="product-cell">
|
||||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
${item.thumbnail
|
||||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" alt="">`
|
||||||
|
: `<div class="product-thumb-placeholder">📦</div>`
|
||||||
|
}
|
||||||
|
<div class="product-info">
|
||||||
|
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||||
|
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${renderCode(item)}</td>
|
<td>${renderCode(item)}</td>
|
||||||
|
|||||||
26
check_images_db.py
Normal file
26
check_images_db.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(r'C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\product_images.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 테이블 목록
|
||||||
|
cursor.execute('SELECT name FROM sqlite_master WHERE type="table"')
|
||||||
|
tables = [r[0] for r in cursor.fetchall()]
|
||||||
|
print("테이블:", tables)
|
||||||
|
|
||||||
|
# 각 테이블 스키마
|
||||||
|
for table in tables:
|
||||||
|
cursor.execute(f'PRAGMA table_info({table})')
|
||||||
|
cols = [r[1] for r in cursor.fetchall()]
|
||||||
|
print(f"\n{table} 컬럼: {cols}")
|
||||||
|
|
||||||
|
# 샘플 데이터
|
||||||
|
cursor.execute(f'SELECT * FROM {table} LIMIT 2')
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for r in rows:
|
||||||
|
print(f" 샘플: {r[:3]}..." if len(r) > 3 else f" 샘플: {r}")
|
||||||
|
|
||||||
|
# 총 개수
|
||||||
|
for table in tables:
|
||||||
|
cursor.execute(f'SELECT COUNT(*) FROM {table}')
|
||||||
|
print(f"\n{table} 총 {cursor.fetchone()[0]}개")
|
||||||
56
package-lock.json
generated
Normal file
56
package-lock.json
generated
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "pharmacy-pos-qr-system",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.58.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.58.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
test_print_cusetc.js
Normal file
39
test_print_cusetc.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// API 응답 캡처
|
||||||
|
let apiResponse = null;
|
||||||
|
page.on('response', async (response) => {
|
||||||
|
if (response.url().includes('/api/print/cusetc')) {
|
||||||
|
apiResponse = await response.json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('http://localhost:7001/admin');
|
||||||
|
console.log('✅ 관리자 페이지');
|
||||||
|
|
||||||
|
await page.click('td:has-text("김영빈")');
|
||||||
|
await page.waitForSelector('text=특이사항', { timeout: 5000 });
|
||||||
|
console.log('✅ 모달 열림');
|
||||||
|
|
||||||
|
// 인쇄 버튼 클릭
|
||||||
|
const printBtn = await page.$('button[onclick*="doPrintCusetc"]');
|
||||||
|
if (printBtn) {
|
||||||
|
await printBtn.click();
|
||||||
|
console.log('✅ 인쇄 버튼 클릭');
|
||||||
|
|
||||||
|
// 즉시 토스트 확인 (API 응답 전)
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const toast = await page.$eval('.toast', el => el?.textContent).catch(() => null);
|
||||||
|
console.log('📢 즉시 피드백:', toast || '(토스트 없음)');
|
||||||
|
|
||||||
|
// API 응답 대기
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('📡 API 응답:', apiResponse?.success ? '✅ ' + apiResponse.message : '❌ ' + (apiResponse?.error || 'no response'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue
Block a user