diff --git a/cope2n-api/fwd/settings.py b/cope2n-api/fwd/settings.py index 7513fcd..5e5ed7b 100755 --- a/cope2n-api/fwd/settings.py +++ b/cope2n-api/fwd/settings.py @@ -241,6 +241,7 @@ BAD_THRESHOLD = 0.75 NEED_REVIEW = 1.0 SUB_FOR_BILLING = ["all", "seao"] +FIELD = ["imei_number", "purchase_date", "retailername", "sold_to_party", "invoice_no"] CACHES = { 'default': { diff --git a/cope2n-api/fwd_api/api/accuracy_view.py b/cope2n-api/fwd_api/api/accuracy_view.py index 787abae..00b354c 100755 --- a/cope2n-api/fwd_api/api/accuracy_view.py +++ b/cope2n-api/fwd_api/api/accuracy_view.py @@ -284,6 +284,7 @@ class AccuracyViewSet(viewsets.ViewSet): return JsonResponse(status=status.HTTP_200_OK, data={"report_id": report_id}) + # Redundant, will be removed by 19 March 2024 @extend_schema( parameters=[ OpenApiParameter( @@ -417,6 +418,9 @@ class AccuracyViewSet(viewsets.ViewSet): acc[key] = report.combined_accuracy.get(key, 0) if report.combined_accuracy else max([fb, rv]) else: acc[key] = None + processing_time = report.average_OCR_time.get("avg", None) if report.average_OCR_time else None + if processing_time and processing_time == 0: + processing_time = None data.append({ "ID": report.id, "Created Date": report.created_at, @@ -429,7 +433,7 @@ class AccuracyViewSet(viewsets.ViewSet): "IMEI Acc": acc["imei_number"], "Avg. Accuracy": acc["avg"], "Avg. Client Request Time": report.average_client_time.get("avg", 0) if report.average_client_time else 0, - "Avg. OCR Processing Time": report.average_OCR_time.get("avg", 0) if report.average_OCR_time else 0, + "Avg. OCR Processing Time": processing_time, "report_id": report.report_id, "Subsidiary": map_subsidiary_short_to_long(report.subsidiary), }) @@ -544,7 +548,7 @@ class AccuracyViewSet(viewsets.ViewSet): for key in keys: if report_fine_data[i][key]: for x_key in report_fine_data[i][key].keys(): - report_fine_data[i][key][x_key] = report_fine_data[i][key][x_key]*100 + report_fine_data[i][key][x_key] = report_fine_data[i][key][x_key]*100 if report_fine_data[i][key][x_key] is not None else None overview_filename = _subsidiary + "_" + duration + ".xlsx" data_workbook = dict2xlsx(report_fine_data, _type='report') diff --git a/cope2n-api/fwd_api/celery_worker/process_report_tasks.py b/cope2n-api/fwd_api/celery_worker/process_report_tasks.py index c62559b..097e9fd 100755 --- a/cope2n-api/fwd_api/celery_worker/process_report_tasks.py +++ b/cope2n-api/fwd_api/celery_worker/process_report_tasks.py @@ -144,6 +144,8 @@ def create_accuracy_report(report_id, **kwargs): report.average_OCR_time = {"invoice": time_cost["invoice"](), "imei": time_cost["imei"](), "invoice_count": time_cost["invoice"].count, "imei_count": time_cost["imei"].count} + report.average_OCR_time["invoice"] = 0 if report.average_OCR_time["invoice"] is None else report.average_OCR_time["invoice"] + report.average_OCR_time["imei"] = 0 if report.average_OCR_time["imei"] is None else report.average_OCR_time["imei"] report.average_OCR_time["avg"] = (report.average_OCR_time["invoice"]*report.average_OCR_time["invoice_count"] + report.average_OCR_time["imei"]*report.average_OCR_time["imei_count"])/( report.average_OCR_time["imei_count"] + report.average_OCR_time["invoice_count"]) if (report.average_OCR_time["imei_count"] + report.average_OCR_time["invoice_count"]) > 0 else None report.number_imei_transaction = transaction_att.get("imei", 0) diff --git a/cope2n-api/fwd_api/management/commands/migrate-database-010224.py b/cope2n-api/fwd_api/management/commands/migrate-database-010224.py index 8788c3c..11befe3 100644 --- a/cope2n-api/fwd_api/management/commands/migrate-database-010224.py +++ b/cope2n-api/fwd_api/management/commands/migrate-database-010224.py @@ -101,8 +101,8 @@ class Command(BaseCommand): request.is_reviewed = False request.save() image.predict_result = _predict_result - image.feedback_result = _feedback_result - image.reviewed_result = _reviewed_result + # image.feedback_result = _feedback_result + # image.reviewed_result = _reviewed_result image.save() except Exception as e: self.stdout.write(self.style.ERROR(f"Request: {request.request_id} failed with {e}")) diff --git a/cope2n-api/fwd_api/management/commands/migrate-database-120324.py b/cope2n-api/fwd_api/management/commands/migrate-database-120324.py new file mode 100644 index 0000000..5f4c5b3 --- /dev/null +++ b/cope2n-api/fwd_api/management/commands/migrate-database-120324.py @@ -0,0 +1,73 @@ +# myapp/management/commands/mycustomcommand.py +from django.core.management.base import BaseCommand +from tqdm import tqdm +from fwd_api.models import SubscriptionRequestFile, SubscriptionRequest +from fwd_api.exception.exceptions import InvalidException +from fwd_api.utils.accuracy import predict_result_to_ready +import traceback +import copy +from django.utils import timezone + +class Command(BaseCommand): + help = 'Move predict result to image level' + + def add_arguments(self, parser): + # Add your command-line arguments here + parser.add_argument('start', type=str, help='start date, sample: 2023-01-02T00:00:00+0700') + parser.add_argument('end', type=str, help='end date, sample: 2023-01-03T00:00:00+0700') + + def process_request(self, request): + if len(request.request_id.split(".")[0].split("_")) < 2: + return + images = SubscriptionRequestFile.objects.filter(request=request) + time_cost = {"imei": [], "invoice": [], "all": []} + if request.ai_inference_profile is None: + time_cost["imei"] = [-1 for _ in range(len(images))] + time_cost["invoice"] = [-1] + time_cost["all"] = [-1] + else: + for k, v in request.ai_inference_profile.items(): + time_cost[k.split("_")[0]].append(v["inference"][1][0] - v["inference"][0] + (v["postprocess"][1]-v["postprocess"][0])) + for i, image in enumerate(images): + try: + image.index_in_request = int(image.file_name.split(".")[0].split("_")[-1]) if len(image.file_name.split(".")[0].split("_")) > 4 else 0 + image.doc_type = image.file_name.split(".")[0].split("_")[1] if len(image.file_name.split(".")[0].split("_")) > 4 else "all" + image.processing_time = time_cost[image.doc_type][image.index_in_request] + if not request.predict_result: + self.stdout.write(self.style.WARNING(f"Key predict_result not found in {request.request_id}")) + return + if request.predict_result.get("status", 200) != 200: + self.stdout.write(self.style.WARNING(f"Failed request: {request.request_id}")) + return + _predict_result = copy.deepcopy(predict_result_to_ready(request.predict_result)) + + if image.doc_type == "invoice": + _predict_result["imei_number"] = [] + else: + _predict_result = {"retailername": None, "sold_to_party": None, "purchase_date": [], "imei_number": [_predict_result["imei_number"][image.index_in_request]]} + image.predict_result = _predict_result + image.save() + except Exception as e: + self.stdout.write(self.style.ERROR(f"Request: {request.request_id} failed with {e}")) + print(traceback.format_exc()) + continue + + def handle(self, *args, **options): + start = options['start'] + end = options['end'] + + if start or end: + try: + start_date = timezone.datetime.strptime(start, '%Y-%m-%dT%H:%M:%S%z') + end_date = timezone.datetime.strptime(end, '%Y-%m-%dT%H:%M:%S%z') + except Exception as e: + print(f"[INFO]: start: {start}") + print(f"[INFO]: end: {end}") + raise InvalidException(excArgs="Date format") + subcription_iter = SubscriptionRequest.objects.filter(created_at__range=(start_date, end_date)) + else: + subcription_iter = SubscriptionRequest.objects.all() + + for request in tqdm(subcription_iter.iterator()): + self.process_request(request) + self.stdout.write(self.style.SUCCESS('Sample Django management command executed successfully!')) diff --git a/cope2n-api/fwd_api/utils/accuracy.py b/cope2n-api/fwd_api/utils/accuracy.py index fa671ab..bd32c70 100755 --- a/cope2n-api/fwd_api/utils/accuracy.py +++ b/cope2n-api/fwd_api/utils/accuracy.py @@ -84,7 +84,7 @@ class ReportAccumulateByRequest: 'bad_percent': 0 }, 'average_accuracy_rate': { - 'imei': IterAvg(), + 'imei_number': IterAvg(), 'purchase_date': IterAvg(), 'retailer_name': IterAvg(), 'sold_to_party': IterAvg(), @@ -133,22 +133,12 @@ class ReportAccumulateByRequest: total["num_imei"] += 1 if doc_type == "imei" else 0 total["num_invoice"] += 1 if doc_type == "invoice" else 0 - if sum([len(report_file.reviewed_accuracy[x]) for x in report_file.reviewed_accuracy.keys() if "_count" not in x]) > 0 : - total["average_accuracy_rate"]["imei"].add(report_file.reviewed_accuracy.get("imei_number", [])) - total["average_accuracy_rate"]["purchase_date"].add(report_file.reviewed_accuracy.get("purchase_date", [])) - total["average_accuracy_rate"]["retailer_name"].add(report_file.reviewed_accuracy.get("retailername", [])) - total["average_accuracy_rate"]["sold_to_party"].add(report_file.reviewed_accuracy.get("sold_to_party", [])) - total["average_accuracy_rate"]["invoice_no"].add(report_file.reviewed_accuracy.get("invoice_no", [])) - elif sum([len(report_file.feedback_accuracy[x]) for x in report_file.feedback_accuracy.keys() if "_count" not in x]) > 0: - total["average_accuracy_rate"]["imei"].add(report_file.feedback_accuracy.get("imei_number", [])) - total["average_accuracy_rate"]["purchase_date"].add(report_file.feedback_accuracy.get("purchase_date", [])) - total["average_accuracy_rate"]["retailer_name"].add(report_file.feedback_accuracy.get("retailername", [])) - total["average_accuracy_rate"]["sold_to_party"].add(report_file.feedback_accuracy.get("sold_to_party", [])) - total["average_accuracy_rate"]["invoice_no"].add(report_file.feedback_accuracy.get("invoice_no", [])) - - for key in ["imei_number", "purchase_date", "invoice_no", "retailername", "sold_to_party"]: + for key in settings.FIELD: + if sum([len(report_file.reviewed_accuracy[x]) for x in report_file.reviewed_accuracy.keys() if "_count" not in x]) > 0 : + total["average_accuracy_rate"][key].add(report_file.reviewed_accuracy.get(key, [])) + elif sum([len(report_file.feedback_accuracy[x]) for x in report_file.feedback_accuracy.keys() if "_count" not in x]) > 0: + total["average_accuracy_rate"][key].add(report_file.feedback_accuracy.get(key, [])) total["feedback_accuracy"][key].add(report_file.feedback_accuracy.get(key, [])) - for key in ["imei_number", "purchase_date", "invoice_no", "retailername", "sold_to_party"]: total["reviewed_accuracy"][key].add(report_file.reviewed_accuracy.get(key, [])) if not total["average_processing_time"].get(report_file.doc_type, None): @@ -182,22 +172,12 @@ class ReportAccumulateByRequest: day_data["num_invoice"] += 1 if doc_type == "invoice" else 0 day_data["report_files"].append(report_file) - if sum([len(report_file.reviewed_accuracy[x]) for x in report_file.reviewed_accuracy.keys() if "_count" not in x]) > 0: - day_data["average_accuracy_rate"]["imei"].add(report_file.reviewed_accuracy.get("imei_number", [])) - day_data["average_accuracy_rate"]["purchase_date"].add(report_file.reviewed_accuracy.get("purchase_date", [])) - day_data["average_accuracy_rate"]["retailer_name"].add(report_file.reviewed_accuracy.get("retailername", [])) - day_data["average_accuracy_rate"]["sold_to_party"].add(report_file.reviewed_accuracy.get("sold_to_party", [])) - day_data["average_accuracy_rate"]["invoice_no"].add(report_file.reviewed_accuracy.get("invoice_no", [])) - elif sum([len(report_file.feedback_accuracy[x]) for x in report_file.feedback_accuracy.keys() if "_count" not in x]) > 0: - day_data["average_accuracy_rate"]["imei"].add(report_file.feedback_accuracy.get("imei_number", [])) - day_data["average_accuracy_rate"]["purchase_date"].add(report_file.feedback_accuracy.get("purchase_date", [])) - day_data["average_accuracy_rate"]["retailer_name"].add(report_file.feedback_accuracy.get("retailername", [])) - day_data["average_accuracy_rate"]["sold_to_party"].add(report_file.feedback_accuracy.get("sold_to_party", [])) - day_data["average_accuracy_rate"]["invoice_no"].add(report_file.feedback_accuracy.get("invoice_no", [])) - - for key in ["imei_number", "purchase_date", "invoice_no", "retailername", "sold_to_party"]: + for key in settings.FIELD: + if sum([len(report_file.reviewed_accuracy[x]) for x in report_file.reviewed_accuracy.keys() if "_count" not in x]) > 0: + day_data["average_accuracy_rate"][key].add(report_file.reviewed_accuracy.get(key, [])) + elif sum([len(report_file.feedback_accuracy[x]) for x in report_file.feedback_accuracy.keys() if "_count" not in x]) > 0: + day_data["average_accuracy_rate"][key].add(report_file.feedback_accuracy.get(key, [])) day_data["feedback_accuracy"][key].add(report_file.feedback_accuracy.get(key, [])) - for key in ["imei_number", "purchase_date", "invoice_no", "retailername", "sold_to_party"]: day_data["reviewed_accuracy"][key].add(report_file.reviewed_accuracy.get(key, [])) if not day_data["average_processing_time"].get(report_file.doc_type, None): @@ -274,7 +254,7 @@ class ReportAccumulateByRequest: "reviewed_accuracy": {}} for acc_type in ["feedback_accuracy", "reviewed_accuracy"]: avg_acc = IterAvg() - for key in ["imei_number", "purchase_date", "invoice_no", "retailername", "sold_to_party"]: + for key in settings.FIELD: acumulated_acc[acc_type][key] = self.data[month][1][day][acc_type][key]() acumulated_acc[acc_type][key+"_count"] = self.data[month][1][day][acc_type][key].count avg_acc.add_avg(acumulated_acc[acc_type][key], acumulated_acc[acc_type][key+"_count"]) @@ -318,26 +298,13 @@ class ReportAccumulateByRequest: for day in _data[month][1].keys(): num_transaction_imei += _data[month][1][day]["usage"].get("imei", 0) num_transaction_invoice += _data[month][1][day]["usage"].get("invoice", 0) - _data[month][1][day]["average_accuracy_rate"]["imei"] = _data[month][1][day]["average_accuracy_rate"]["imei"]() - _data[month][1][day]["average_accuracy_rate"]["purchase_date"] = _data[month][1][day]["average_accuracy_rate"]["purchase_date"]() - _data[month][1][day]["average_accuracy_rate"]["retailer_name"] = _data[month][1][day]["average_accuracy_rate"]["retailer_name"]() - _data[month][1][day]["average_accuracy_rate"]["sold_to_party"] = _data[month][1][day]["average_accuracy_rate"]["sold_to_party"]() - _data[month][1][day]["average_accuracy_rate"]["invoice_no"] = _data[month][1][day]["average_accuracy_rate"]["invoice_no"]() for key in _data[month][1][day]["average_processing_time"].keys(): _data[month][1][day]["average_processing_time"][key] = _data[month][1][day]["average_processing_time"][key]() - _data[month][1][day]["feedback_accuracy"]["imei_number"] = _data[month][1][day]["feedback_accuracy"]["imei_number"]() - _data[month][1][day]["feedback_accuracy"]["purchase_date"] = _data[month][1][day]["feedback_accuracy"]["purchase_date"]() - _data[month][1][day]["feedback_accuracy"]["retailername"] = _data[month][1][day]["feedback_accuracy"]["retailername"]() - _data[month][1][day]["feedback_accuracy"]["sold_to_party"] = _data[month][1][day]["feedback_accuracy"]["sold_to_party"]() - _data[month][1][day]["feedback_accuracy"]["invoice_no"] = _data[month][1][day]["feedback_accuracy"]["invoice_no"]() - - _data[month][1][day]["reviewed_accuracy"]["imei_number"] = _data[month][1][day]["reviewed_accuracy"]["imei_number"]() - _data[month][1][day]["reviewed_accuracy"]["purchase_date"] = _data[month][1][day]["reviewed_accuracy"]["purchase_date"]() - _data[month][1][day]["reviewed_accuracy"]["retailername"] = _data[month][1][day]["reviewed_accuracy"]["retailername"]() - _data[month][1][day]["reviewed_accuracy"]["sold_to_party"] = _data[month][1][day]["reviewed_accuracy"]["sold_to_party"]() - _data[month][1][day]["reviewed_accuracy"]["invoice_no"] = _data[month][1][day]["reviewed_accuracy"]["invoice_no"]() - + for key in settings.FIELD: + _data[month][1][day]["average_accuracy_rate"][key] = _data[month][1][day]["average_accuracy_rate"][key]() + for accuracy_type in ["feedback_accuracy", key]: + _data[month][1][day][accuracy_type]["imei_number"] = _data[month][1][day]["feedback_accuracy"]["imei_number"]() _data[month][1][day]["review_progress"] = _data[month][1][day]["review_progress"].count(1)/(_data[month][1][day]["review_progress"].count(0)+ _data[month][1][day]["review_progress"].count(1)) if (_data[month][1][day]["review_progress"].count(0)+ _data[month][1][day]["review_progress"].count(1)) >0 else 0 _data[month][1][day].pop("report_files") @@ -347,28 +314,13 @@ class ReportAccumulateByRequest: _data[month][0]["usage"]["imei"] = num_transaction_imei _data[month][0]["usage"]["invoice"] = num_transaction_invoice _data[month][0]["usage"]["total_images"] = num_transaction_invoice + num_transaction_imei - _data[month][0]["average_accuracy_rate"]["imei"] = _data[month][0]["average_accuracy_rate"]["imei"]() - _data[month][0]["average_accuracy_rate"]["purchase_date"] = _data[month][0]["average_accuracy_rate"]["purchase_date"]() - _data[month][0]["average_accuracy_rate"]["retailer_name"] = _data[month][0]["average_accuracy_rate"]["retailer_name"]() - _data[month][0]["average_accuracy_rate"]["sold_to_party"] = _data[month][0]["average_accuracy_rate"]["sold_to_party"]() - _data[month][0]["average_accuracy_rate"]["invoice_no"] = _data[month][0]["average_accuracy_rate"]["invoice_no"]() for key in _data[month][0]["average_processing_time"].keys(): _data[month][0]["average_processing_time"][key] = _data[month][0]["average_processing_time"][key]() - - _data[month][0]["feedback_accuracy"]["imei_number"] = _data[month][0]["feedback_accuracy"]["imei_number"]() - _data[month][0]["feedback_accuracy"]["purchase_date"] = _data[month][0]["feedback_accuracy"]["purchase_date"]() - _data[month][0]["feedback_accuracy"]["retailername"] = _data[month][0]["feedback_accuracy"]["retailername"]() - _data[month][0]["feedback_accuracy"]["sold_to_party"] = _data[month][0]["feedback_accuracy"]["sold_to_party"]() - _data[month][0]["feedback_accuracy"]["invoice_no"] = _data[month][0]["feedback_accuracy"]["invoice_no"]() - - _data[month][0]["reviewed_accuracy"]["imei_number"] = _data[month][0]["reviewed_accuracy"]["imei_number"]() - _data[month][0]["reviewed_accuracy"]["purchase_date"] = _data[month][0]["reviewed_accuracy"]["purchase_date"]() - _data[month][0]["reviewed_accuracy"]["retailername"] = _data[month][0]["reviewed_accuracy"]["retailername"]() - _data[month][0]["reviewed_accuracy"]["sold_to_party"] = _data[month][0]["reviewed_accuracy"]["sold_to_party"]() - _data[month][0]["reviewed_accuracy"]["invoice_no"] = _data[month][0]["reviewed_accuracy"]["invoice_no"]() - + for key in settings.FIELD: + _data[month][0]["average_accuracy_rate"][key] = _data[month][0]["average_accuracy_rate"][key]() + for accuracy_type in ["feedback_accuracy", key]: + _data[month][0][accuracy_type][key] = _data[month][0][accuracy_type][key]() _data[month][0]["review_progress"] = _data[month][0]["review_progress"].count(1)/(_data[month][0]["review_progress"].count(0)+ _data[month][0]["review_progress"].count(1)) if (_data[month][0]["review_progress"].count(0)+ _data[month][0]["review_progress"].count(1)) >0 else 0 - return _data class MonthReportAccumulate: @@ -580,6 +532,7 @@ def first_of_list(the_list): def extract_report_detail_list(report_detail_list, lower=False, in_percent=True): data = [] for report_file in report_detail_list: + # FIXME: #79 Fill None with value data.append({ "Subs": report_file.subsidiary, "Request ID": report_file.correspond_request_id, @@ -587,12 +540,19 @@ def extract_report_detail_list(report_detail_list, lower=False, in_percent=True) "Image type": report_file.doc_type, "IMEI_user submitted": first_of_list(report_file.feedback_result.get("imei_number", [None])), "IMEI_OCR retrieved": first_of_list(report_file.predict_result.get("imei_number", [None])), + "IMEI Revised": None, "IMEI1 Accuracy": first_of_list(report_file.feedback_accuracy.get("imei_number", [None])), + "Invoice_Number_User": None, + "Invoice_Number_OCR": None, + "Invoice_Number Revised": None, + "Invoice_Number_Accuracy": None, "Invoice_Purchase Date_Consumer": report_file.feedback_result.get("purchase_date", None), "Invoice_Purchase Date_OCR": report_file.predict_result.get("purchase_date", []), + "Invoice_Purchase Date Revised": None, "Invoice_Purchase Date Accuracy": first_of_list(report_file.feedback_accuracy.get("purchase_date", [None])), "Invoice_Retailer_Consumer": report_file.feedback_result.get("retailername", None), "Invoice_Retailer_OCR": report_file.predict_result.get("retailername", None), + "Invoice_Purchase Date Revised": None, "Invoice_Retailer Accuracy": first_of_list(report_file.feedback_accuracy.get("retailername", [None])), "Invoice_No_Consumer": report_file.feedback_result.get("invoice_no", None), "Invoice_No_OCR": report_file.predict_result.get("invoice_no", None), @@ -811,6 +771,12 @@ def create_billing_data(subscription_requests): return billing_data def calculate_a_request(report, request): + def review_status_map(input): + review_status = {-1: "Not Required", + 0: "No", + 1: "Yes"} + return review_status.get(input, "N/A") + request_att = {"acc": {"feedback": {"imei_number": [], "purchase_date": [], "retailername": [], @@ -871,8 +837,8 @@ def calculate_a_request(report, request): if len(att["normalized_data"]["reviewed"].get("purchase_date", [])) > 0: image.predict_result["purchase_date"] = [att["normalized_data"]["reviewed"]["purchase_date"][i][0] for i in range(len(att["normalized_data"]["reviewed"]["purchase_date"]))] image.reviewed_result["purchase_date"] = att["normalized_data"]["reviewed"]["purchase_date"][rv_max_indexes["purchase_date"]][1] - if request.is_reviewed: - att["is_reviewed"] = 1 + # if request.is_reviewed: + # att["is_reviewed"] = 1 request_att["is_reviewed"].append(att["is_reviewed"]) new_report_file = ReportFile(report=report, subsidiary=_sub, @@ -886,7 +852,7 @@ def calculate_a_request(report, request): reviewed_accuracy=att["acc"]["reviewed"], acc=att["avg_acc"], is_bad_image=att["is_bad_image"], - is_reviewed= "Yes" if request.is_reviewed else "No", + is_reviewed= review_status_map(att["is_reviewed"]), time_cost=image.processing_time, bad_image_reason=image.reason, counter_measures=image.counter_measures, @@ -979,6 +945,10 @@ def calculate_subcription_file(subcription_request_file): avg_acc = avg_reviewed att["is_reviewed"] = 1 + # Little trick to overcome issue caused by misleading manually review process + if subcription_request_file.reason or subcription_request_file.counter_measures: + att["is_reviewed"] = 1 + att["avg_acc"] = avg_acc if avg_acc < settings.BAD_THRESHOLD: att["is_bad_image"] = True diff --git a/cope2n-api/fwd_api/utils/file.py b/cope2n-api/fwd_api/utils/file.py index 2df0d41..fea47c0 100755 --- a/cope2n-api/fwd_api/utils/file.py +++ b/cope2n-api/fwd_api/utils/file.py @@ -487,9 +487,10 @@ def dict2xlsx(input: json, _type='report'): 'M': 'average_accuracy_rate.imei', 'N': 'average_accuracy_rate.purchase_date', 'O': 'average_accuracy_rate.retailer_name', - 'P': 'average_processing_time.imei', - 'Q': 'average_processing_time.invoice', - 'R': 'review_progress' + 'P': 'average_accuracy_rate.invoice_number', + 'Q': 'average_processing_time.imei', + 'R': 'average_processing_time.invoice', + 'S': 'review_progress' } start_index = 5 @@ -503,21 +504,29 @@ def dict2xlsx(input: json, _type='report'): 'D': 'image_type', 'E': 'imei_user_submitted', 'F': "imei_ocr_retrieved", - 'G': "imei1_accuracy", - 'H': "invoice_purchase_date_consumer", - 'I': "invoice_purchase_date_ocr", - 'J': "invoice_purchase_date_accuracy", - 'K': "invoice_retailer_consumer", - 'L': "invoice_retailer_ocr", - 'M': "invoice_retailer_accuracy", - 'N': "ocr_image_accuracy", - 'O': "ocr_image_speed_(seconds)", - 'P': "is_reviewed", - 'Q': "bad_image_reasons", - 'R': "countermeasures", - 'S': 'imei_revised_accuracy', - 'T': 'purchase_date_revised_accuracy', - 'U': 'retailer_revised_accuracy', + 'G': "imei_revised", + 'H': "imei1_accuracy", + 'I': "invoice_number_user", + 'J': "invoice_number_ocr", + 'K': "invoice_number_revised", + 'L': "invoice_number_accuracy", + 'M': "invoice_purchase_date_consumer", + 'N': "invoice_purchase_date_ocr", + 'O': "invoice_purchase_date_revised", + 'P': "invoice_purchase_date_accuracy", + 'Q': "invoice_retailer_consumer", + 'R': "invoice_retailer_ocr", + 'S': 'invoice_retailer_revised', + 'T': "invoice_retailer_accuracy", + 'U': "ocr_image_accuracy", + 'V': "ocr_image_speed_(seconds)", + 'W': "is_reviewed", + 'X': "bad_image_reasons", + 'Y': "countermeasures", + 'Z': "imei_revised_accuracy", + 'AA': "invoice_number_revised_accuracy", + 'AB': 'purchase_date_revised_accuracy', + 'AC': 'retailer_revised_accuracy', } start_index = 4 diff --git a/cope2n-api/report.xlsx b/cope2n-api/report.xlsx index 3319dae..92da45f 100644 Binary files a/cope2n-api/report.xlsx and b/cope2n-api/report.xlsx differ diff --git a/cope2n-api/report_detail.xlsx b/cope2n-api/report_detail.xlsx index 82e12dd..480d827 100644 Binary files a/cope2n-api/report_detail.xlsx and b/cope2n-api/report_detail.xlsx differ diff --git a/cope2n-fe/.env.development b/cope2n-fe/.env.development index 5150853..a27afc6 100644 --- a/cope2n-fe/.env.development +++ b/cope2n-fe/.env.development @@ -1,3 +1,3 @@ VITE_PORT=8080 -VITE_PROXY=https://107.120.133.22/ +VITE_PROXY=http://42.96.42.13:9881 VITE_KUBEFLOW_HOST=https://107.120.133.22:8085 \ No newline at end of file diff --git a/cope2n-fe/src/components/report-detail/report-overview-table.tsx b/cope2n-fe/src/components/report-detail/report-overview-table.tsx index 5a40ea6..cc2b3b1 100644 --- a/cope2n-fe/src/components/report-detail/report-overview-table.tsx +++ b/cope2n-fe/src/components/report-detail/report-overview-table.tsx @@ -1,7 +1,7 @@ import type { TableColumnsType } from 'antd'; import { Table } from 'antd'; import React from 'react'; -import { ensureMax, ensureMin, formatPercent } from 'utils/metric-format'; +import { ensureMax, ensureMin, formatPercent, formatNumber } from 'utils/metric-format'; interface DataType { key: React.Key; @@ -224,7 +224,7 @@ const columns: TableColumnsType = [ const isAbnormal = ensureMax(record.snImeiAPT, 2); return ( - {record?.snImeiAPT?.toFixed(1)} + {formatNumber(record?.snImeiAPT)} ); }, @@ -237,7 +237,7 @@ const columns: TableColumnsType = [ const isAbnormal = ensureMax(record.invoiceAPT, 2); return ( - {record?.invoiceAPT?.toFixed(1)} + {formatNumber(record?.invoiceAPT)} ); }, @@ -252,7 +252,7 @@ const columns: TableColumnsType = [ render: (_, record) => { return ( - {formatPercent(record.reviewProgress)==='-'? 0:formatPercent(record.reviewProgress)} + {formatPercent(record.reviewProgress) === '-' ? 0 : formatPercent(record.reviewProgress)} ); }, diff --git a/cope2n-fe/src/components/report-detail/report-table.tsx b/cope2n-fe/src/components/report-detail/report-table.tsx index 0156358..c59099d 100644 --- a/cope2n-fe/src/components/report-detail/report-table.tsx +++ b/cope2n-fe/src/components/report-detail/report-table.tsx @@ -5,7 +5,7 @@ import { useReportList } from 'queries/report'; import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { downloadReport } from 'request/report'; -import { formatPercent, ensureMin, ensureMax } from 'utils/metric-format'; +import { formatPercent, ensureMin, ensureMax, formatNumber } from 'utils/metric-format'; import { datetimeStrToDate } from 'utils/time'; @@ -125,8 +125,7 @@ const ReportTable: React.FC = () => { const isAbnormal = ensureMin(record['IMEI Acc'], 0.95); return ( - {record['IMEI Acc'] && - formatPercent(Number(record['IMEI Acc']))} + {formatPercent(record['IMEI Acc'])} ); }, @@ -139,22 +138,20 @@ const ReportTable: React.FC = () => { const isAbnormal = ensureMin(record['Avg. Accuracy'], 0.95); return ( - {record['Avg. Accuracy'] && - formatPercent(Number(record['Avg. Accuracy']))} + {formatPercent(record['Avg. Accuracy'])} ); }, }, { - title: 'Avg. OCR Processing Time', + title: 'Avg. OCR Processing Time (ms)', dataIndex: 'Avg. OCR Processing Time', key: 'Avg. OCR Processing Time', render: (_, record) => { const isAbnormal = ensureMax(record['Avg. OCR Processing Time'], 2); return ( - {record['Avg. OCR Processing Time'] && - Number(record['Avg. OCR Processing Time'])?.toFixed(2)} + {formatNumber(record['Avg. OCR Processing Time'], 1)} ); }, diff --git a/cope2n-fe/src/locales/en/messages.json b/cope2n-fe/src/locales/en/messages.json index e21a284..eaee229 100644 --- a/cope2n-fe/src/locales/en/messages.json +++ b/cope2n-fe/src/locales/en/messages.json @@ -11,11 +11,14 @@ "Email format is not correct": "Email format is not correct", "English": "English", "Go to Reports": "Go to Reports", + "Handwritten": "Handwritten", "Inference": "Inference", + "Invalid image": "Invalid image", "Is Test": "Is Test", "Language": "Language", "Login": "Login", "Logout": "Logout", + "Missing information": "Missing information", "New Report": "New Report", "Only characters (a-z), (A-Z), (0-9), @, ., +, -, _ are available": "Only characters (a-z), (A-Z), (0-9), @, ., +, -, _ are available", "Password": "Password", @@ -28,8 +31,11 @@ "Please enter a valid domain": "Please enter a valid domain", "Please specify a password": "Please specify a password", "Please specify a username": "Please specify a username", + "Reason for bad quality:": "Reason for bad quality:", + "Recheck": "Recheck", "Report Details": "Report Details", "Report Filters": "Report Filters", + "Report Type": "Report Type", "Reports": "Reports", "Retry": "Retry", "Review": "Review", @@ -43,6 +49,8 @@ "This field must not have more than {MAX_EMAIL_LENGTH} characters": "This field must not have more than {MAX_EMAIL_LENGTH} characters", "This field must not have more than {MAX_STRING_LENGTH} characters": "This field must not have more than {MAX_STRING_LENGTH} characters", "This field must not have more than {MAX_USERNAME_LENGTH} characters": "This field must not have more than {MAX_USERNAME_LENGTH} characters", + "Too blurry text": "Too blurry text", + "Too small text": "Too small text", "Upload files to process. The requests here will not be used in accuracy or payment calculations.": "Upload files to process. The requests here will not be used in accuracy or payment calculations.", "User log in successfully": "User log in successfully", "Username": "Username", diff --git a/cope2n-fe/src/locales/vi/messages.json b/cope2n-fe/src/locales/vi/messages.json index 950c2bb..07a56ed 100644 --- a/cope2n-fe/src/locales/vi/messages.json +++ b/cope2n-fe/src/locales/vi/messages.json @@ -11,11 +11,14 @@ "Email format is not correct": "Định dạng email không hợp lệ", "English": "Tiếng Anh", "Go to Reports": "", + "Handwritten": "", "Inference": "", + "Invalid image": "", "Is Test": "", "Language": "Ngôn ngữ", "Login": "Đăng nhập", "Logout": "Đăng xuất", + "Missing information": "", "New Report": "", "Only characters (a-z), (A-Z), (0-9), @, ., +, -, _ are available": "Chỉ cho phép các ký tự (a-z), (A-Z), (0-9), @, ., +, -, _", "Password": "Mật khẩu", @@ -28,8 +31,11 @@ "Please enter a valid domain": "Vui lòng nhập một tên miền hợp lệ", "Please specify a password": "Vui lòng nhập một mật khẩu", "Please specify a username": "Vui lòng nhập một tên tài khoản", + "Reason for bad quality:": "", + "Recheck": "", "Report Details": "", "Report Filters": "", + "Report Type": "", "Reports": "", "Retry": "Thử lại", "Review": "", @@ -43,6 +49,8 @@ "This field must not have more than {MAX_EMAIL_LENGTH} characters": "Độ dài chuỗi không được vượt quá {MAX_EMAIL_LENGTH} kí tự", "This field must not have more than {MAX_STRING_LENGTH} characters": "Độ dài chuỗi không được vượt quá {MAX_STRING_LENGTH} kí tự", "This field must not have more than {MAX_USERNAME_LENGTH} characters": "Độ dài chuỗi không được vượt quá {MAX_USERNAME_LENGTH} kí tự", + "Too blurry text": "", + "Too small text": "", "Upload files to process. The requests here will not be used in accuracy or payment calculations.": "", "User log in successfully": "Đăng nhập thành công", "Username": "Tên tài khoản", diff --git a/cope2n-fe/src/models/report.ts b/cope2n-fe/src/models/report.ts index 23db378..58b8af2 100644 --- a/cope2n-fe/src/models/report.ts +++ b/cope2n-fe/src/models/report.ts @@ -65,6 +65,7 @@ export interface MakeReportParams { start_date: string; end_date: string; subsidiary: string; + report_type: string; } export type CustomUseMutationOptions< diff --git a/cope2n-fe/src/pages/inference/index.tsx b/cope2n-fe/src/pages/inference/index.tsx index 82f24a4..0cd53cb 100644 --- a/cope2n-fe/src/pages/inference/index.tsx +++ b/cope2n-fe/src/pages/inference/index.tsx @@ -1,21 +1,219 @@ import { t } from '@lingui/macro'; -import { Button, message, Upload } from 'antd'; +import { Button, Input, Table, Tag, DatePicker, Form, Modal, Select, Spin, message, Upload } from 'antd'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { GetRef } from 'antd'; +import { Layout } from 'antd'; +import { + DownloadOutlined, CheckCircleOutlined, + ClockCircleFilled, +} from '@ant-design/icons'; +import styled from 'styled-components'; +const { Sider, Content } = Layout; +import { baseURL } from "request/api"; +import { Viewer } from '@react-pdf-viewer/core'; import { SbtPageHeader } from 'components/page-header'; -import { useState } from 'react'; import { UploadOutlined } from '@ant-design/icons'; import type { GetProp, UploadFile, UploadProps } from 'antd'; -import { JsonView, allExpanded, defaultStyles } from 'react-json-view-lite'; import 'react-json-view-lite/dist/index.css'; -import { baseURL } from "request/api" type FileType = Parameters>[0]; +const ENABLE_REVIEW = true; + + +// Import the styles +import '@react-pdf-viewer/core/lib/styles/index.css'; + + +const siderStyle: React.CSSProperties = { + backgroundColor: '#fafafa', + padding: 10, + width: 200, +}; + + +const StyledTable = styled(Table)` + & .sbt-table-cell { + padding: 4px!important; + } +`; + +type InputRef = GetRef; +type FormInstance = GetRef>; + +const EditableContext = React.createContext | null>(null); + +interface Item { + key: string; + accuracy: number; + revised: string; + action: string; +} + +interface EditableRowProps { + index: number; +} + +const EditableRow: React.FC = ({ index, ...props }) => { + const [form] = Form.useForm(); + return ( +
+ + + +
+ ); +}; + +interface EditableCellProps { + title: React.ReactNode; + editable: boolean; + children: React.ReactNode; + dataIndex: keyof Item; + record: Item; + handleSave: (record: Item) => void; +} + + +const EditableCell: React.FC = ({ + title, + editable, + children, + dataIndex, + record, + handleSave, + ...restProps +}) => { + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const form = useContext(EditableContext)!; + + useEffect(() => { + if (editing) { + inputRef.current!.focus(); + } + }, [editing]); + + const toggleEdit = () => { + setEditing(!editing); + form.setFieldsValue({ [dataIndex]: record[dataIndex] }); + }; + + const save = async () => { + try { + const values = await form.validateFields(); + + toggleEdit(); + handleSave({ ...record, ...values }); + } catch (errInfo) { + console.log('Save failed:', errInfo); + } + }; + + let childNode = children; + + if (editable) { + childNode = editing ? ( + + + + ) : ( +
+ {children} +
+ ); + } + + return {childNode}; +}; + +// type EditableTableProps = Parameters[0]; + +const FileCard = ({ file, isSelected, onClick, setIsReasonModalOpen }) => { + const fileName = file["File Name"]; + + return ( +
+
+ {file["Doc Type"].toUpperCase()} + + {fileName ? fileName.substring(0, 25).replace("temp_", "") : fileName} + +
+
+ {/* */} + +
+
+ ); + +}; + +const fetchRequest = async (id) => { + const token = localStorage.getItem('sbt-token') || ''; + const response = await fetch(`${baseURL}/ctel/request/${id}/`, { + method: 'GET', + headers: { + "Authorization": `${JSON.parse(token)}` + } + }); + return await (await response.json()).subscription_requests[0]; +}; + const InferencePage = () => { const [invoiceFiles, setInvoiceFiles] = useState([]); const [imei1Files, setImei1Files] = useState([]); const [imei2Files, setImei2Files] = useState([]); const [uploading, setUploading] = useState(false); - const [jsonData, setJsonData] = useState({}); + const [responseData, setResponseData] = useState(null); const [finishedProcessing, setFinishedProcessing] = useState(false); const handleUpload = () => { @@ -31,9 +229,7 @@ const InferencePage = () => { } formData.append('is_test_request', 'true'); setUploading(true); - setJsonData({ - "message": "Please wait..." - }) + setResponseData(null); const token = localStorage.getItem('sbt-token') || ''; fetch(`${baseURL}/ctel/images/process_sync/`, { method: 'POST', @@ -42,112 +238,509 @@ const InferencePage = () => { "Authorization": `${JSON.parse(token)}` } }) - .then(async(res) => { + .then(async (res) => { const data = await res.json(); - setJsonData(data); + if (data["status"] != "200") { + setResponseData(null); + return; + } + setTimeout(() => { + loadRequestById(data["request_id"]); + }, 2000); setFinishedProcessing(true); return data; }) .then(() => { message.success('Upload successfully.'); }) - .catch(() => { + .catch((e) => { + console.log(e); message.error('Upload failed.'); }) .finally(() => { setUploading(false); }); }; + const [loading, setLoading] = useState(false); + const [isReasonModalOpen, setIsReasonModalOpen] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(0); + const [selectedFileData, setSelectedFileData] = useState(null); + const [selectedFileName, setSelectedFileName] = useState(null); + const [currentRequest, setCurrentRequest] = useState(null); + const [dataSource, setDataSource] = useState([]); + + const setAndLoadSelectedFile = async (requestData, index) => { + setSelectedFileId(index); + if (!requestData["Files"][index]) { + setSelectedFileData("FAILED_TO_LOAD_FILE"); + return; + }; + const fileName = requestData["Files"][index]["File Name"]; + const fileURL = requestData["Files"][index]["File URL"]; + const response = await fetch(fileURL); + if (response.status === 200) { + setSelectedFileName(fileName); + setSelectedFileData(fileURL); + console.log("Loading file: " + fileName); + console.log("URL: " + fileURL); + } else { + setSelectedFileData("FAILED_TO_LOAD_FILE"); + } + }; + + const loadRequestById = (requestID) => { + setLoading(true); + const requestData = fetchRequest(requestID); + requestData.then(async (data) => { + if (data) setCurrentRequest(data); + const predicted = (data && data["Predicted Result"]) ? data["Predicted Result"] : {}; + const revised = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {}; + const keys = Object.keys(predicted); + const tableRows = []; + for (let i = 0; i < keys.length; i++) { + let instance = {}; + instance["key"] = keys[i]; + instance["predicted"] = predicted[keys[i]]; + instance["revised"] = revised[keys[i]]; + tableRows.push(instance); + } + setDataSource(tableRows); + setLoading(false); + setAndLoadSelectedFile(data, 0); + }).finally(() => { + setLoading(false); + }); + }; + + const components = { + body: { + row: EditableRow, + cell: EditableCell, + }, + }; + + // "Key", "Accuracy", "Revised" + interface DataType { + key: string; + accuracy: number; + revised: string; + }; + + const updateRevisedData = async (newRevisedData: any) => { + const requestID = currentRequest.RequestID; + const token = localStorage.getItem('sbt-token') || ''; + await fetch(`${baseURL}/ctel/request/${requestID}/`, { + method: 'POST', + headers: { + "Authorization": `${JSON.parse(token)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "reviewed_result": newRevisedData + }), + }).catch((error) => { + console.log(error); + message.error("Could not update revised data"); + }); + }; + + const handleSave = (row: DataType) => { + const newData = [...dataSource]; + const index = newData.findIndex((item) => row.key === item.key); + const item = newData[index]; + newData.splice(index, 1, { + ...item, + ...row, + }); + setDataSource(newData); + const newRevisedData = {}; + for (let i = 0; i < newData.length; i++) { + newRevisedData[newData[i].key] = newData[i].revised; + } + updateRevisedData(newRevisedData).then(() => { + // "[Is Reviewed]" => true + setCurrentRequest({ + ...currentRequest, + ["Is Reviewed"]: true, + }) + }) + }; + + const submitRevisedData = async () => { + const newData = [...dataSource]; + const newRevisedData = {}; + for (let i = 0; i < newData.length; i++) { + if (newData[i].revised === "") { + newData[i].revised = null; + } + if (typeof(newData[i].revised) === "string") { + newData[i].revised = newData[i].revised.trim(); + } + if (newData[i].revised === "" || newData[i].revised === null || newData[i].revised === undefined) { + newData[i].revised = null; + } + if ((newData[i].key === "imei_number" || newData[i].key === "purchase_date") && typeof(newData[i].revised) === "string") { + // Convert to list + newData[i].revised = new Array(newData[i].revised.split(",")); + } + if (Array.isArray(newData[i].revised)) { + // Trim all empty strings + for (let j = 0; j < newData[i].revised.length; j++) { + if (typeof(newData[i].revised[j]) === "string") { + newData[i].revised[j] = newData[i].revised[j].trim(); + } + if (newData[i].revised[j] === "") { + newData[i].revised[j] = null; + } + } + } + newRevisedData[newData[i].key] = newData[i].revised; + } + updateRevisedData(newRevisedData).then(() => { + // "[Is Reviewed]" => true + setCurrentRequest({ + ...currentRequest, + ["Is Reviewed"]: true, + }) + }) + }; + + + const defaultColumns = [ + { + title: 'Key', + dataIndex: 'key', + key: 'key', + width: 200, + }, + { + title: 'Predicted', + dataIndex: 'predicted', + key: 'predicted', + render: (text) => { + if (!text) return {""}; + const displayedContent = text; + if (typeof(displayedContent) === "string") { + return {displayedContent}; + } else if (typeof(displayedContent) === "object") { + if (displayedContent.length === 0) { + return {""}; + } + // Set all empty values to "" + for (const key in displayedContent) { + if (!displayedContent[key]) { + displayedContent[key] = ""; + } + } + return {displayedContent.join(", ")}; + } + return {displayedContent}; + }, + }, + { + title: (
Revised   + {ENABLE_REVIEW && } +
), + dataIndex: 'revised', + key: 'revised', + editable: ENABLE_REVIEW, + render: (text) => { + if (!text) return {""}; + const displayedContent = text; + if (typeof(displayedContent) === "string") { + return {displayedContent}; + } else if (typeof(displayedContent) === "object") { + if (displayedContent.length === 0) { + return {""}; + } + // Set all empty values to "" + for (const key in displayedContent) { + if (!displayedContent[key]) { + displayedContent[key] = ""; + } + } + return {displayedContent.join(", ")}; + } + return {displayedContent}; + }, + }, + ]; + + + const columns = defaultColumns.map((col) => { + if (!col.editable) { + return col; + } + return { + ...col, + onCell: (record: DataType) => ({ + record, + editable: col.key != "request_id" && col.editable, + dataIndex: col.dataIndex, + title: col.title, + handleSave, + }), + }; + }); + const fileExtension = selectedFileName ? selectedFileName.split('.').pop() : ''; return ( <> - -

- {t`Upload files to process. The requests here will not be used in accuracy or payment calculations.`} -

- { - if (finishedProcessing) return; - setInvoiceFiles([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setInvoiceFiles([file]) - return false; - }} - fileList={invoiceFiles} + +
+
+ { + if (finishedProcessing) return; + setInvoiceFiles([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setInvoiceFiles([file]) + return false; + }} + fileList={invoiceFiles} + > + Invoice: + +
+
+ { + if (finishedProcessing) return; + setImei1Files([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setImei1Files([file]) + return false; + }} + fileList={imei1Files} + > + IMEI 1: + +
+
+ { + if (finishedProcessing) return; + setImei2Files([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setImei2Files([file]) + return false; + }} + fileList={imei2Files} + > + IMEI 2: + +
+
+ {!finishedProcessing && } + {finishedProcessing && } +
+
+
- Invoice File: - -
-
- { - if (finishedProcessing) return; - setImei1Files([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setImei1Files([file]) - return false; - }} - fileList={imei1Files} + +
+ + + {currentRequest?.Files?.length &&
+

Files ({currentRequest?.Files?.length})

+ {currentRequest?.Files.map((file, index) => ( + { + setAndLoadSelectedFile(currentRequest, index); + } + } setIsReasonModalOpen={setIsReasonModalOpen} /> + ))} +
} + {selectedFileData &&
+ {selectedFileData === "FAILED_TO_LOAD_FILE" ?

: (fileExtension === "pdf" ? () :
file
)} +
} +
+ +
+ + + + + +
+ {currentRequest && (currentRequest["Is Reviewed"] ? } color="success" style={{ padding: "4px 16px" }}> + Reviewed + : } color="warning" style={{ padding: "4px 16px" }}> + Not Reviewed + )} +
+
+
+
+ { + + } + } + onCancel={ + () => { + setIsReasonModalOpen(false); + } + } > - IMEI File 1: -
-
-
- { - if (finishedProcessing) return; - setImei2Files([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setImei2Files([file]) - return false; - }} - fileList={imei2Files} - > - IMEI File 2: - -
- {!finishedProcessing && } - {finishedProcessing && } -
-

Result:

- +
+ + + + + +