From 90a79b6d039af550bd8edc695ba89e028c265975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=88=98=20=EB=B0=95?= Date: Thu, 5 Jun 2025 14:15:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[fix]=20participant=20post=20get=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/views.py b/api/views.py index ac33e1a..1e4924c 100644 --- a/api/views.py +++ b/api/views.py @@ -117,7 +117,8 @@ class ParticipantViewSet(viewsets.ViewSet): serializer_class = ParticipantSerializer @method_decorator(csrf_exempt, name='dispatch') - def create(self, request): + @action(detail=False, methods=['post'], url_path='create') + def create_participant(self, request): """ 참가자 추가 API @@ -179,7 +180,8 @@ def create(self, request): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @method_decorator(csrf_exempt, name='dispatch') - def list(self, request): + @action(detail=False, methods=['get'], url_path='list') + def list_participants(self, request): """ 참가자 목록 조회 API From 1a5b1ac4e247395a3d333cc4bebd85549a6daba3 Mon Sep 17 00:00:00 2001 From: chlgmltn Date: Thu, 5 Jun 2025 17:21:10 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FEAT]=20=EC=A0=95=EC=82=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=97=91=EC=85=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/migrations/0002_settlement.py | 28 +++++++ api/urls.py | 2 + api/views.py | 120 ++++++++++++++++++++++++------ requirements.txt | 1 + 4 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 api/migrations/0002_settlement.py diff --git a/api/migrations/0002_settlement.py b/api/migrations/0002_settlement.py new file mode 100644 index 0000000..0080962 --- /dev/null +++ b/api/migrations/0002_settlement.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.1 on 2025-06-05 15:38 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Settlement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('result', models.JSONField()), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('participants', models.ManyToManyField(to='api.participant')), + ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.receipt')), + ], + options={ + 'db_table': 'settlement', + }, + ), + ] diff --git a/api/urls.py b/api/urls.py index 1635e7f..27fe636 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from . import views +from .views import export_settlement_excel app_name = 'api' @@ -11,4 +12,5 @@ urlpatterns = [ path('', include(router.urls)), # 영수증 업로드 API + path('settlement/export_excel//', export_settlement_excel, name='export_settlement_excel'),#엑셀 추출 ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index b72d398..231287e 100644 --- a/api/views.py +++ b/api/views.py @@ -8,6 +8,8 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from .models import Receipt, Participant, ReceiptInfo, Settlement +from django.http import HttpResponse +from openpyxl import Workbook from .serializers import ReceiptSerializer, ParticipantSerializer, ReceiptInfoSerializer, SettlementSerializer from api.ocr_pipeline.preprocessing import preprocess_image_to_memory from api.ocr_pipeline.image_to_text import ocr_image_from_memory @@ -196,44 +198,116 @@ class SettlementViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['post'], url_path='calculate') def calculate_settlement(self, request): """ - POST /api/settlement/calculate/ - 정산 계산 수행 - - Request Body 예시: + 정산 계산 API + + --- + 정산 계산을 수행합니다. + + ### Request Body { "receipt_id": 1, - "participants": [1, 2, 3] + "items": [ + { "item_name": "김밥", "participants": ["최희수"] }, + { "item_name": "라면", "participants": ["하승연", "최희수"] } + ] } + + ### Responses + - 200: 정산 완료 + ```json + { + "success": true, + "message": "정산이 완료되었습니다.", + "result": { + "참가자1": 5000, + "참가자2": 5000 + } + } + ``` + - 400: 필수값 누락/항목 부족 + ```json + { + "error": "receipt_id와 participants는 필수입니다." + } + ``` + - 500: 서버 오류 + ```json + { + "success": false, + "error": "...에러메시지..." + } + ``` +>>>>>>> a378daf ([FEAT] 정산 수정 엑셀 기능 추가) """ try: - receipt_id = request.data.get('receipt_id') - participant_ids = request.data.get('participants', []) + receipt_id = request.data.get("receipt_id") + item_assignments = request.data.get("items", []) - if not receipt_id or not participant_ids: - return Response({'error': 'receipt_id와 participants는 필수입니다.'}, status=400) + if not receipt_id or not item_assignments: + return Response({'error': 'receipt_id와 items 필수'}, status=400) receipt = Receipt.objects.get(id=receipt_id) - items = receipt.items.all() # related_name='items'를 통해 ReceiptInfo 접근 - num_participants = len(participant_ids) + items = receipt.items.all() # ReceiptInfo 리스트 + + result = {} # {참여자이름: 금액} - if num_participants == 0 or not items.exists(): - return Response({'error': '참가자나 항목이 부족합니다.'}, status=400) + for assignment in item_assignments: + item_name = assignment.get("item_name") + names = assignment.get("participants", []) - # 전체 금액 계산 - total = sum(item.total_amount for item in items) - share = total // num_participants + matched_items = items.filter(item_name=item_name) + if not matched_items.exists(): + continue # 해당 항목 없음 - participant_objs = Participant.objects.filter(id__in=participant_ids) - result = {p.name: share for p in participant_objs} + for item in matched_items: + share = item.total_amount // max(len(names), 1) + for name in names: + result[name] = result.get(name, 0) + share + # Settlement 저장 settlement = Settlement.objects.create(receipt=receipt, result=result) - settlement.participants.set(participant_objs) + participants = Participant.objects.filter(name__in=result.keys()) + settlement.participants.set(participants) return Response({ - 'success': True, - 'message': '정산이 완료되었습니다.', - 'result': result + "success": True, + "message": "정산이 완료되었습니다.", + "result": result }) except Exception as e: - return Response({'success': False, 'error': str(e)}, status=500) \ No newline at end of file + return Response({'success': False, 'error': str(e)}, status=500) + +def export_settlement_excel(request, settlement_id): + from datetime import datetime + + settlement = Settlement.objects.get(id=settlement_id) + receipt = settlement.receipt + receipt_infos = receipt.items.all() # related_name='items'로 ReceiptInfo 접근 + + wb = Workbook() + ws = wb.active + ws.title = "정산 결과" + + # 1. 상호명 및 업로드일 + ws.append(["상호명", receipt_infos.first().store_name if receipt_infos.exists() else "정보 없음"]) + ws.append(["업로드일", receipt.upload_time.strftime('%Y-%m-%d %H:%M:%S')]) + ws.append([]) + + # 2. 메뉴 목록 + ws.append(["메뉴명", "수량", "단가", "총액"]) + for info in receipt_infos: + ws.append([info.item_name, info.quantity, info.unit_price, info.total_amount]) + ws.append([]) + + # 3. 정산 결과 + ws.append(["참여자", "정산 금액"]) + for name, amount in settlement.result.items(): + ws.append([name, amount]) + + # 응답 반환 + response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + filename = f"settlement_{settlement_id}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + wb.save(response) + return response \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 574bdfa..0475931 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,3 +54,4 @@ tqdm==4.67.1 typing_extensions==4.13.2 tzdata==2025.2 uritemplate==4.1.1 +openpyxl>=3.1.2 \ No newline at end of file From 31f9b0c2b4c2072cbe38d1db30e328754e997032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=88=98=20=EB=B0=95?= Date: Thu, 5 Jun 2025 17:46:09 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[fix]=20participant=20post=20get=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/views.py | 4 +- main/templates/main/receipt_test.html | 813 +++++++++++++++----------- 2 files changed, 486 insertions(+), 331 deletions(-) diff --git a/api/views.py b/api/views.py index 1e4924c..015626e 100644 --- a/api/views.py +++ b/api/views.py @@ -117,7 +117,7 @@ class ParticipantViewSet(viewsets.ViewSet): serializer_class = ParticipantSerializer @method_decorator(csrf_exempt, name='dispatch') - @action(detail=False, methods=['post'], url_path='create') + @action(detail=False, methods=['POST'], url_path='join') def create_participant(self, request): """ 참가자 추가 API @@ -180,7 +180,7 @@ def create_participant(self, request): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @method_decorator(csrf_exempt, name='dispatch') - @action(detail=False, methods=['get'], url_path='list') + @action(detail=False, methods=['GET'], url_path='members') def list_participants(self, request): """ 참가자 목록 조회 API diff --git a/main/templates/main/receipt_test.html b/main/templates/main/receipt_test.html index d3382ed..ec5bd21 100644 --- a/main/templates/main/receipt_test.html +++ b/main/templates/main/receipt_test.html @@ -1,373 +1,528 @@ - - - + + + PayCheck - - -
-
- ▮▮▮▮▮ + + +
+
+ ▮▮▮▮▮ PayCheck -
-
+
+

영수증 이미지 첨부

- - - 이미지를 첨부하세요 + + + 이미지를 첨부하세요
- - - -
+ + + +
-
+

참여자 리스트

-
- - -
-
+
+ + +
+
+ + + + + +
+
API 요청 결과:
+

+          
+
+
- -
- - - - \ No newline at end of file + }); + } + }); + + + +ㄴ From 814ce41c56d8b782ce90526f4d710d79829b9a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=88=98=20=EB=B0=95?= Date: Fri, 6 Jun 2025 14:01:47 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[feat]=20db=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/apps.py | 24 ++++++++++++++++++- api/management/commands/reset_local_db.py | 29 +++++++++++++++++++++++ api/models.py | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 api/management/commands/reset_local_db.py diff --git a/api/apps.py b/api/apps.py index 66656fd..ed54620 100644 --- a/api/apps.py +++ b/api/apps.py @@ -1,6 +1,28 @@ +import os +import sys from django.apps import AppConfig - +from django.core.management import call_command class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + # runserver 시에만 실행 (migrate, shell 등에서는 실행 안 됨) + if 'runserver' in sys.argv and not self._is_reloading(): + self.reset_database_on_startup() + + def _is_reloading(self): + """Django autoreload로 인한 재시작인지 확인""" + return os.environ.get('RUN_MAIN') == 'true' + + def reset_database_on_startup(self): + """서버 시작 시 기존 reset_local_db 명령어 실행""" + try: + print('🔄 서버 시작 시 데이터베이스 자동 초기화...') + + # 기존에 만든 reset_local_db 명령어 실행 + call_command('reset_local_db') + + except Exception as e: + print(f'❌ 자동 초기화 실패: {e}') \ No newline at end of file diff --git a/api/management/commands/reset_local_db.py b/api/management/commands/reset_local_db.py new file mode 100644 index 0000000..6033082 --- /dev/null +++ b/api/management/commands/reset_local_db.py @@ -0,0 +1,29 @@ +import os +import shutil +from django.core.management.base import BaseCommand +from django.conf import settings +from api.models import Receipt, Participant, ReceiptInfo, Settlement + +class Command(BaseCommand): + help = '로컬 MySQL 데이터베이스 초기화' + + def handle(self, *args, **options): + self.stdout.write('🔄 로컬 데이터베이스 초기화 시작...') + + # 1. 모든 데이터 삭제 (역순으로 - 외래키 때문에) + self.stdout.write('🗃️ 데이터베이스 데이터 삭제 중...') + # Settlement.objects.all().delete() + ReceiptInfo.objects.all().delete() + Participant.objects.all().delete() + Receipt.objects.all().delete() + + # 2. 미디어 파일 삭제 + self.stdout.write('📁 업로드된 파일 삭제 중...') + media_receipts = os.path.join(settings.MEDIA_ROOT, 'receipts') + if os.path.exists(media_receipts): + shutil.rmtree(media_receipts) + os.makedirs(media_receipts, exist_ok=True) + + self.stdout.write( + self.style.SUCCESS('🎉 로컬 데이터베이스 초기화 완료!') + ) \ No newline at end of file diff --git a/api/models.py b/api/models.py index 4b75655..8832b57 100644 --- a/api/models.py +++ b/api/models.py @@ -65,4 +65,4 @@ class Meta: db_table = 'settlement' # MySQL 테이블 이름 지정 def __str__(self): - return f"Settlement for Receipt {self.receipt.id} - {self.method}" \ No newline at end of file + return f"Settlement for Receipt {self.receipt.id} - {self.created_at}" \ No newline at end of file From b44b01d8eb00bfacd3a52b1306f93fd71916ae6c Mon Sep 17 00:00:00 2001 From: chlgmltn Date: Fri, 6 Jun 2025 18:21:09 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=EC=A0=95=EC=82=B0=20method=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/migrations/0003_settlement_method.py | 18 +++++++++ api/models.py | 5 +++ api/views.py | 49 ++++++++++++++++-------- 3 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 api/migrations/0003_settlement_method.py diff --git a/api/migrations/0003_settlement_method.py b/api/migrations/0003_settlement_method.py new file mode 100644 index 0000000..8a9bc49 --- /dev/null +++ b/api/migrations/0003_settlement_method.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.1 on 2025-06-06 18:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_settlement'), + ] + + operations = [ + migrations.AddField( + model_name='settlement', + name='method', + field=models.CharField(choices=[('equal', 'Equal Split (N분의1)'), ('item', 'Item Split (항목별)')], default='equal', max_length=10), + ), + ] diff --git a/api/models.py b/api/models.py index 4b75655..927c630 100644 --- a/api/models.py +++ b/api/models.py @@ -55,10 +55,15 @@ def __str__(self): return f"Participant {self.id}: {self.name}" class Settlement(models.Model): + METHOD_CHOICES = [ + ('equal', 'Equal Split (N분의1)'), + ('item', 'Item Split (항목별)'), + ] receipt = models.ForeignKey('Receipt', on_delete=models.CASCADE) participants = models.ManyToManyField('Participant') result = models.JSONField() # {'홍길동': 3000, '김철수': 3000} + method = models.CharField(max_length=10, choices=METHOD_CHOICES, default='equal') created_at = models.DateTimeField(default=timezone.now) class Meta: diff --git a/api/views.py b/api/views.py index d61750e..d8ad679 100644 --- a/api/views.py +++ b/api/views.py @@ -382,9 +382,11 @@ def calculate_settlement(self, request): --- 정산 계산을 수행합니다. - ### Request Body + request body 예시: { "receipt_id": 1, + "method": "equal" or "item", + "participants": ["하승연", "최희수"], "items": [ { "item_name": "김밥", "participants": ["최희수"] }, { "item_name": "라면", "participants": ["하승연", "최희수"] } @@ -419,28 +421,45 @@ def calculate_settlement(self, request): """ try: receipt_id = request.data.get("receipt_id") + method = request.data.get("method", "equal") + participant_names = request.data.get("participants", []) item_assignments = request.data.get("items", []) - if not receipt_id or not item_assignments: - return Response({'error': 'receipt_id와 items 필수'}, status=400) + if not receipt_id: + return Response({'error': 'receipt_id는 필수입니다.'}, status=400) receipt = Receipt.objects.get(id=receipt_id) items = receipt.items.all() # ReceiptInfo 리스트 - result = {} # {참여자이름: 금액} + result = {} + + if method == "equal": + if not participant_names: + return Response({'error': '1/N 정산은 participants 필수입니다.'}, status=400) + + total = sum(item.total_amount for item in items) + share = total // len(participant_names) + for name in participant_names: + result[name] = share + + elif method == "item": + if not item_assignments: + return Response({'error': '항목별 정산은 items 필수입니다.'}, status=400) - for assignment in item_assignments: - item_name = assignment.get("item_name") - names = assignment.get("participants", []) + for assignment in item_assignments: + item_name = assignment.get("item_name") + names = assignment.get("participants", []) + matched_items = items.filter(item_name=item_name) - matched_items = items.filter(item_name=item_name) - if not matched_items.exists(): - continue # 해당 항목 없음 + if not matched_items.exists(): + continue - for item in matched_items: - share = item.total_amount // max(len(names), 1) - for name in names: - result[name] = result.get(name, 0) + share + for item in matched_items: + share = item.total_amount // max(len(names), 1) + for name in names: + result[name] = result.get(name, 0) + share + else: + return Response({'error': 'method는 "equal" 또는 "item"이어야 합니다.'}, status=400) # Settlement 저장 settlement = Settlement.objects.create(receipt=receipt, result=result) @@ -454,7 +473,7 @@ def calculate_settlement(self, request): }) except Exception as e: - return Response({'success': False, 'error': str(e)}, status=500) + return Response({'success': False, 'error': str(e)}, status=500) def export_settlement_excel(request, settlement_id): from datetime import datetime From 89af123cc5678a658ea057063b606098694938c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=88=98=20=EB=B0=95?= Date: Sat, 7 Jun 2025 01:10:29 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[fix]=20=EC=83=81=ED=92=88=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/ocr_pipeline/dictionary_store_item.json | 33 ++++ api/ocr_pipeline/extract_item2.py | 185 ++++++++++++++++++++ api/ocr_pipeline/process_text.py | 77 +++++++- api/views.py | 12 +- 4 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 api/ocr_pipeline/dictionary_store_item.json create mode 100644 api/ocr_pipeline/extract_item2.py diff --git a/api/ocr_pipeline/dictionary_store_item.json b/api/ocr_pipeline/dictionary_store_item.json new file mode 100644 index 0000000..4bfb2c7 --- /dev/null +++ b/api/ocr_pipeline/dictionary_store_item.json @@ -0,0 +1,33 @@ +{ + "stores": { + "동국대학교소비자생활협동조합": { + "items": [ + "콘치즈솥밥", + "삼겹김치철판", + "치즈불닭철판", + "잔치국수", + "데리야끼치킨솥밥" + ] + }, + "교직원식당": { + "items": ["식권7000"] + }, + "동국대학교 폴바셋": { + "items": ["ICE 카페라떼"] + }, + "동국대 남산학사 리김밥": { + "items": ["리라면", "야채김밥", "눈꽃치즈라볶이"] + }, + "coopsket": { + "items": [ + "슈가로로코코제로요구르트", + "e)신선함을그대로망고펫500", + "썬키스트허니유자펫280ml", + "아임이)빅얼음컵230g", + "인절미맛마시는빙수340ml", + "영진)참치김치삼각김밥2편", + "대정)소고기고추장삼각2편" + ] + } + } +} diff --git a/api/ocr_pipeline/extract_item2.py b/api/ocr_pipeline/extract_item2.py new file mode 100644 index 0000000..fe9b6fd --- /dev/null +++ b/api/ocr_pipeline/extract_item2.py @@ -0,0 +1,185 @@ +import re +import math +import os +from .process_text import TextPostProcessor + +def normalize_number(text): + if not text: + return text + text = re.sub(r'O', '0', text) # O를 0으로 변환 (OCR 오류 보정) + text = re.sub(r'[,.\s]', '', text) # 콤마, 점, 공백 제거 + return text + +def is_number_format(text): + """텍스트가 숫자 형식인지 확인""" + try: + int(normalize_number(text)) + return True + except ValueError: + return False + +def extract_numbers_from_line(words): + """줄에서 숫자 형식의 단어들을 추출""" + numbers = [] + for word in words: + if is_number_format(word): + try: + numbers.append(int(normalize_number(word))) + except ValueError: + continue + return numbers + +def extract_menu_items_from_lines(lines): + """ + 사전 기반 유사도 매칭을 사용한 메뉴 항목 추출 + TextPostProcessor를 사용해서 dictionary_store_item.json 로드 + """ + # JSON 사전 파일을 사용하는 TextPostProcessor 생성 + dict_path = os.path.join(os.path.dirname(__file__), 'dictionary_store_item.json') + processor = TextPostProcessor(dict_path=dict_path) + + menu_items = [] + store_name = None + store_found = False + last_successful_line = -1 # 마지막으로 성공적으로 메뉴가 추가된 줄 번호 + + # 1) 맨 위부터 읽으면서 가게명 찾기 + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + if not store_found: + # 2) 가게명과 유사도 검사 + # 전체 줄에서 가게명 매칭 시도 + match, score = processor.find_best_store_match(line) + if match: + store_name = match + store_found = True + print(f"🏪 가게명 발견: {line} → {match} (유사도: {score:.2f})") + break + + # 단어별로도 시도 + words = line.split() + for word in words: + match, score = processor.find_best_store_match(word) + if match: + store_name = match + store_found = True + print(f"🏪 가게명 발견: {word} → {match} (유사도: {score:.2f})") + break + + if store_found: + break + + # 3) 가게명을 찾았다면, 이후 다시 txt를 읽어서 메뉴 항목 찾기 + if store_found and store_name: + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + words = line.split() + if len(words) == 0: + continue + print(f"📄 [{i}] 현재 줄: '{line}'") + # 연속성 체크: 첫 번째 메뉴가 아니고 이전 성공 줄과 너무 멀면 중단 + if last_successful_line != -1 and i > last_successful_line + 2: + break + + # 4) 첫 번째 단어부터 시작해서 누적적으로 확장하며 최고 유사도 찾기 + best_match = None + best_score = 0 + best_end_index = -1 + best_test_phrase = None # 실제 매칭된 구문 저장 + + # 첫 번째 단어부터만 시작 + for k in range(0, len(words)): + # 0부터 k까지의 단어들을 합침 + if k == 0: + # 첫 번째 단어만 + test_phrase = words[0] + else: + # 여러 단어를 띄어쓰기로 연결 + test_phrase = " ".join(words[0:k+1]) + + # 메뉴 사전과 비교 + match, score = processor.find_best_item_match(test_phrase, store_name) + + if match and score > best_score: + best_match = match + best_score = score + best_end_index = k + best_test_phrase = test_phrase # 실제 매칭된 구문 저장 + + # 숫자가 나타나면 더 이상 확장하지 않음 + if k < len(words) - 1 and is_number_format(words[k+1]): + break + + # 최고 매칭이 있다면 처리 + if best_match and best_score >= 0.4: # 임계값 + # 매칭된 부분 다음부터 숫자 추출 + remaining_words = words[best_end_index + 1:] + numbers = extract_numbers_from_line(remaining_words) + + menu_added = False + + if len(numbers) == 1: + # 4.1) 숫자 하나: 총액 = 개당 가격 + total_amount = numbers[0] + unit_price = total_amount + quantity = 1 + + menu_items.append({ + "item_name": best_match, + "unit_price": unit_price, + "total_amount": total_amount, + "quantity": quantity + }) + print(f"🍔 메뉴 발견: {best_test_phrase} → {best_match} (유사도: {best_score:.2f})") + print(f" → 개당: {unit_price}원, 개수: {quantity}개, 총액: {total_amount}원") + menu_added = True + + elif len(numbers) == 2: + # 4.2) 숫자 두개: 첫번째=개당가격, 두번째=총액 + unit_price = numbers[0] + total_amount = numbers[1] + quantity = total_amount // unit_price if unit_price > 0 else 1 + + menu_items.append({ + "item_name": best_match, + "unit_price": unit_price, + "total_amount": total_amount, + "quantity": quantity + }) + print(f"🍔 메뉴 발견: {best_test_phrase} → {best_match} (유사도: {best_score:.2f})") + print(f" → 개당: {unit_price}원, 개수: {quantity}개, 총액: {total_amount}원") + menu_added = True + + elif len(numbers) >= 3: + # 4.3) 숫자 세개 이상: 첫번째=개당가격, 두번째=개수, 세번째=총액 + unit_price = numbers[0] + quantity = numbers[1] + total_amount = numbers[2] + + menu_items.append({ + "item_name": best_match, + "unit_price": unit_price, + "total_amount": total_amount, + "quantity": quantity + }) + print(f"🍔 메뉴 발견: {best_test_phrase} → {best_match} (유사도: {best_score:.2f})") + print(f" → 개당: {unit_price}원, 개수: {quantity}개, 총액: {total_amount}원") + menu_added = True + + # 메뉴가 성공적으로 추가되었으면 마지막 성공 줄 번호 업데이트 + if menu_added: + last_successful_line = i + + result = { + "store_name": store_name, + "items": menu_items, + } + + print(f"✅ 항목 추출 완료 → {store_name or '상호명 없음'} ({len(menu_items)}개)\n") + return result \ No newline at end of file diff --git a/api/ocr_pipeline/process_text.py b/api/ocr_pipeline/process_text.py index 65c35b0..88b096b 100644 --- a/api/ocr_pipeline/process_text.py +++ b/api/ocr_pipeline/process_text.py @@ -1,10 +1,17 @@ import re +import json import Levenshtein class TextPostProcessor: def __init__(self, dict_path="dictionary.txt"): self.dict_path = dict_path - self.dictionary = self.load_dictionary(dict_path) + self.store_item_path = dict_path.endswith('.json') + + # 파일 타입에 따라 다른 로딩 방식 사용 + if self.store_item_path: + self._load_json_dictionary() + else: + self._load_text_dictionary() self.chosung_list = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'] self.jungsung_list = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', @@ -13,15 +20,67 @@ def __init__(self, dict_path="dictionary.txt"): 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'] - def load_dictionary(self, dict_path): - dictionary = [] + def _load_text_dictionary(self): + """텍스트 파일 로딩 - dictionary만 생성""" + self.stores_dict = {} + try: + with open(self.dict_path, 'r', encoding='utf-8') as f: + self.dictionary = [line.strip() for line in f if line.strip()] + except Exception: + self.dictionary = [] + + def _load_json_dictionary(self): + """JSON 파일 로딩 - stores_dict만 생성""" + self.dictionary = [] try: - with open(dict_path, 'r', encoding='utf-8') as f: - dictionary = [line.strip() for line in f if line.strip()] + with open(self.dict_path, 'r', encoding='utf-8') as f: + data = json.load(f) + self.stores_dict = data.get("stores", {}) except Exception: - pass - return dictionary + self.stores_dict = {} + + def find_best_store_match(self, target, threshold=0.4): + """가게명에서 가장 유사한 매치 찾기 (JSON 전용)""" + if not self.store_item_path or not self.stores_dict: + return None, 0 + + best_match = None + max_similarity = 0 + + for store_name in self.stores_dict.keys(): + if abs(len(target) - len(store_name)) > len(target) / 2: + continue + + similarity = self.calculate_jamo_similarity(target, store_name) + if similarity > max_similarity: + max_similarity = similarity + best_match = store_name + elif similarity == max_similarity and len(store_name) > len(best_match or ""): + best_match = store_name + + return (best_match, max_similarity) if max_similarity >= threshold else (None, 0) + def find_best_item_match(self, target, store_name, threshold=0.4): + """특정 가게의 메뉴에서 가장 유사한 매치 찾기 (JSON 전용)""" + if not self.store_item_path or not self.stores_dict or store_name not in self.stores_dict: + return None, 0 + + items_list = self.stores_dict[store_name].get("items", []) + best_match = None + max_similarity = 0 + + for item in items_list: + if abs(len(target) - len(item)) > len(target) / 2: + continue + similarity = self.calculate_jamo_similarity(target, item) + if similarity > max_similarity: + max_similarity = similarity + best_match = item + elif similarity == max_similarity and len(item) > len(best_match or ""): + best_match = item + + return (best_match, max_similarity) if max_similarity >= threshold else (None, 0) + def decompose_hangul(self, text): result = [] for char in text: @@ -73,6 +132,10 @@ def normalize_number(self, text): text = re.sub(r'\bO(\d+)', r'0\g<1>', text) text = re.sub(r'(\d*[,\.])O(\d*)', r'\g<1>0\g<2>', text) text = re.sub(r'(\d*)U(\d*)', r'\g<1>0\g<2>', text) + text = re.sub(r'(\d*)E(\d*)', r'\g<1>0\g<2>', text) + text = re.sub(r'(\d+)E\b', r'\g<1>0', text) + text = re.sub(r'\bE(\d+)', r'0\g<1>', text) + text = re.sub(r'(\d*[,\.])E(\d*)', r'\g<1>0\g<2>', text) text = re.sub(r'(\d*)\((\d*)', r'\1\2', text) text = re.sub(r'(\d*)\)(\d*)', r'\1\2', text) text = re.sub(r'(\d+)\s*,\s*(\d+)', r'\1,\2', text) diff --git a/api/views.py b/api/views.py index 90b0f3b..46e77b0 100644 --- a/api/views.py +++ b/api/views.py @@ -14,7 +14,7 @@ from api.ocr_pipeline.preprocessing import preprocess_image_to_memory from api.ocr_pipeline.image_to_text import ocr_image_from_memory from api.ocr_pipeline.process_text import TextPostProcessor -from api.ocr_pipeline.extract_item import extract_menu_items_from_lines +from api.ocr_pipeline.extract_item2 import extract_menu_items_from_lines import os import uuid @@ -307,8 +307,6 @@ def analyze_receipts(self, request): return Response({'success': False, 'error': '분석할 영수증이 없습니다.'}, status=400) processor = TextPostProcessor(dict_path=os.path.join(settings.BASE_DIR, 'api', 'ocr_pipeline', 'dictionary.txt')) - store_processor = TextPostProcessor(dict_path=os.path.join(settings.BASE_DIR, 'api', 'ocr_pipeline', 'dictionary_store.txt')) - item_processor = TextPostProcessor(dict_path=os.path.join(settings.BASE_DIR, 'api', 'ocr_pipeline', 'dictionary_item.txt')) serialized_items = [] for receipt in receipts: @@ -332,17 +330,13 @@ def analyze_receipts(self, request): # 5. 가게명/음식명 각각 find_closest_word 적용 store_name = result.get("store_name", "") - fixed_store_name = store_processor.find_closest_word(store_name, 0.4) or store_name - items = result.get("items") or [] # None이면 빈 리스트로 대체 for item in items: - item_name = item.get("item_name", "") - fixed_item_name = item_processor.find_closest_word(item_name, 0.4) or item_name data = { "receipt": receipt.id, - "store_name": fixed_store_name, - "item_name": fixed_item_name, + "store_name": store_name, + "item_name": item["item_name"], "quantity": item["quantity"], "unit_price": item["unit_price"], "total_amount": item["total_amount"], From 4924ef14ed5696e1a163b513299b7387ee4f43b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=88=98=20=EB=B0=95?= Date: Sat, 7 Jun 2025 01:50:51 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[fix]=20=EC=B6=9C=EB=A0=A5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/ocr_pipeline/extract_item2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ocr_pipeline/extract_item2.py b/api/ocr_pipeline/extract_item2.py index fe9b6fd..8276d7a 100644 --- a/api/ocr_pipeline/extract_item2.py +++ b/api/ocr_pipeline/extract_item2.py @@ -82,7 +82,7 @@ def extract_menu_items_from_lines(lines): words = line.split() if len(words) == 0: continue - print(f"📄 [{i}] 현재 줄: '{line}'") + # 연속성 체크: 첫 번째 메뉴가 아니고 이전 성공 줄과 너무 멀면 중단 if last_successful_line != -1 and i > last_successful_line + 2: break