Update utility.py
Browse files- utility.py +176 -40
utility.py
CHANGED
|
@@ -120,6 +120,65 @@ except Exception as e:
|
|
| 120 |
logger.error(f"Error configuring Generative AI: {e}", exc_info=True)
|
| 121 |
model = vision_model = llm = None
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
# --- START: VISION PROCESSING FUNCTIONS ---
|
| 124 |
|
| 125 |
def _transpile_vision_json_to_query(vision_json: List[Dict]) -> str:
|
|
@@ -165,7 +224,6 @@ def _analyze_image_with_vision(image_bytes: bytes, caption: Optional[str]) -> Li
|
|
| 165 |
try:
|
| 166 |
image_pil = Image.open(io.BytesIO(image_bytes))
|
| 167 |
|
| 168 |
-
# --- FIX BUG 2: Reinforce Receipt vs Product logic ---
|
| 169 |
prompt = f"""
|
| 170 |
You are a bookkeeping vision model. Analyze the image. Return ONLY a valid JSON array [] of transaction objects.
|
| 171 |
|
|
@@ -205,16 +263,6 @@ DETAILS KEYS
|
|
| 205 |
- liability: creditor, amount, currency
|
| 206 |
- query: query (verbatim text)
|
| 207 |
|
| 208 |
-
================
|
| 209 |
-
EXAMPLES
|
| 210 |
-
================
|
| 211 |
-
|
| 212 |
-
// 1) Photo of a dress on a hanger (User is selling it)
|
| 213 |
-
[ {{"intent":"create","transaction_type":"sale","details":{{"item":"dress","quantity":1}},"source":"object_detection"}} ]
|
| 214 |
-
|
| 215 |
-
// 2) Paper Receipt for "City Power" (User paid this)
|
| 216 |
-
[ {{"intent":"create","transaction_type":"expense","details":{{"description":"electricity","amount":500,"currency":"R","vendor":"City Power"}},"source":"ocr"}} ]
|
| 217 |
-
|
| 218 |
Analyze the provided image and return only the JSON list.
|
| 219 |
"""
|
| 220 |
|
|
@@ -339,7 +387,6 @@ class ReportEngine:
|
|
| 339 |
num_transactions = len(target_df)
|
| 340 |
item_summary = target_df.groupby('item')['quantity'].sum()
|
| 341 |
|
| 342 |
-
# --- FIX BUG 3: Logic for ties and single items ---
|
| 343 |
if item_summary.empty:
|
| 344 |
best_selling_item = "N/A"
|
| 345 |
worst_selling_item = "N/A"
|
|
@@ -536,14 +583,12 @@ class ReportEngine:
|
|
| 536 |
|
| 537 |
return snapshot
|
| 538 |
|
| 539 |
-
# --- FIX BUG 4: Accept Currency argument ---
|
| 540 |
def generateResponse(prompt: str, currency: str = "R") -> str:
|
| 541 |
"""Generate structured JSON response from user input using Generative AI."""
|
| 542 |
if not model:
|
| 543 |
return '{"error": "Model not available"}'
|
| 544 |
|
| 545 |
-
# ---
|
| 546 |
-
# NOTE: {currency} uses single braces for variable. {{}} uses double braces for literal JSON.
|
| 547 |
system_prompt = f"""
|
| 548 |
Analyze the user's request for business transaction management. Your goal is to extract structured information about one or more transactions and output it as a valid JSON list.
|
| 549 |
|
|
@@ -555,30 +600,38 @@ You MUST output your response as a valid JSON list `[]` containing one or more t
|
|
| 555 |
|
| 556 |
**2. Transaction Object Structure:**
|
| 557 |
Each transaction object MUST have the following keys:
|
| 558 |
-
- `"intent"`: The user's goal (
|
| 559 |
-
- `"transaction_type"`: The category of the transaction (e.g., "sale", "purchase", "inventory", "expense", "asset", "liability", "query", "service_offering").
|
| 560 |
- `"details"`: An object containing key-value pairs extracted from the request.
|
| 561 |
|
| 562 |
**3. Key Naming Conventions for the `details` Object:**
|
| 563 |
- **For Expenses:** Use `"amount"`, `"description"`, and `"category"`.
|
| 564 |
- **For Assets:** Use `"value"` for the monetary worth and `"name"` for the item's name.
|
| 565 |
- **For Liabilities:** Use `"amount"` and `"creditor"`.
|
| 566 |
-
- **For Sales/Inventory:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
- **For all financial transactions:** Always include a `"currency"` key. Use user input if available, else default to {currency}.
|
| 568 |
|
| 569 |
-
**4.
|
| 570 |
-
- **
|
| 571 |
-
- **Rule for Expense Normalization:** For "create" intents with `transaction_type` "expense", analyze the `description`. If it contains common keywords, normalize it to a single word (e.g., "fuel for truck" -> "fuel").
|
| 572 |
|
| 573 |
**5. Examples:**
|
| 574 |
|
| 575 |
-
**Example 1:
|
| 576 |
-
- **Input:** "
|
| 577 |
-
- **Output:** [ {{"intent": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
-
**Example
|
| 580 |
-
- **Input:** "
|
| 581 |
-
- **Output:** [ {{"intent": "
|
| 582 |
"""
|
| 583 |
try:
|
| 584 |
full_prompt = [system_prompt, prompt]
|
|
@@ -619,8 +672,6 @@ def _get_canonical_info(user_phone: str, item_name: str) -> Dict[str, Any]:
|
|
| 619 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory_and_services")
|
| 620 |
name_lower = item_name.lower().strip()
|
| 621 |
|
| 622 |
-
# --- FIX BUG 1: Singularization Edge Case ---
|
| 623 |
-
# Don't singularize words ending in 'ss' (Dress, Glass, Pass)
|
| 624 |
if name_lower.endswith('ss'):
|
| 625 |
singular = name_lower
|
| 626 |
else:
|
|
@@ -707,12 +758,14 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
|
|
| 707 |
all_sales_docs.sort(key=lambda doc: doc.to_dict().get('timestamp', ''), reverse=True)
|
| 708 |
last_sale_data = all_sales_docs[0].to_dict()
|
| 709 |
last_selling_price = last_sale_data.get('details', {}).get('price')
|
|
|
|
| 710 |
@firestore.transactional
|
| 711 |
def process_one_sale(transaction, sale_details):
|
| 712 |
is_new_item = canonical_info['doc'] is None
|
| 713 |
original_trans_type = t.get('transaction_type')
|
| 714 |
item_type = 'service' if original_trans_type == 'service_offering' else 'good'
|
| 715 |
user_price = sale_details.get('price') or sale_details.get('unit_price')
|
|
|
|
| 716 |
if user_price is not None:
|
| 717 |
selling_price = user_price
|
| 718 |
elif last_selling_price is not None:
|
|
@@ -723,15 +776,45 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
|
|
| 723 |
else:
|
| 724 |
selling_price = 0
|
| 725 |
if not isinstance(selling_price, (int, float)): selling_price = 0
|
|
|
|
| 726 |
sale_details['price'] = selling_price
|
| 727 |
sale_details['item'] = canonical_name
|
| 728 |
if 'unit_price' in sale_details: del sale_details['unit_price']
|
| 729 |
if 'service_name' in sale_details: del sale_details['service_name']
|
|
|
|
| 730 |
try:
|
| 731 |
quantity_sold = int(sale_details.get('quantity', 1))
|
| 732 |
if quantity_sold <= 0: return f"Sale failed for '{canonical_name}': Invalid quantity ({quantity_sold})."
|
| 733 |
except (ValueError, TypeError):
|
| 734 |
return f"Sale failed for '{canonical_name}': Invalid quantity format."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
item_doc_ref = db.collection("users").document(user_phone).collection("inventory_and_services").document(canonical_name)
|
| 736 |
item_snapshot = item_doc_ref.get(transaction=transaction)
|
| 737 |
item_cost = 0
|
|
@@ -755,15 +838,23 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
|
|
| 755 |
'last_updated': datetime.now(timezone.utc).isoformat()
|
| 756 |
}
|
| 757 |
transaction.set(item_doc_ref, service_record)
|
|
|
|
| 758 |
sale_doc_ref = sales_ref.document()
|
| 759 |
sale_record = {
|
| 760 |
'details': {**sale_details, 'cost': item_cost},
|
| 761 |
'timestamp': datetime.now(timezone.utc).isoformat(),
|
| 762 |
-
'status':
|
| 763 |
'transaction_id': sale_doc_ref.id
|
| 764 |
}
|
| 765 |
transaction.set(sale_doc_ref, sale_record)
|
| 766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
transaction_feedback = process_one_sale(db.transaction(), details)
|
| 768 |
feedback_messages.append(transaction_feedback)
|
| 769 |
if "successful" in transaction_feedback:
|
|
@@ -1092,9 +1183,12 @@ def read_datalake(user_phone: str, query: str) -> str:
|
|
| 1092 |
|
| 1093 |
def _find_document_by_details(user_phone: str, collection_name: str, details: Dict) -> Optional[Any]:
|
| 1094 |
col_ref = db.collection("users").document(user_phone).collection(collection_name)
|
|
|
|
|
|
|
| 1095 |
if 'transaction_id' in details and details['transaction_id']:
|
| 1096 |
doc = col_ref.document(details['transaction_id']).get()
|
| 1097 |
if doc.exists: return {"id": doc.id, "data": doc.to_dict()}
|
|
|
|
| 1098 |
if collection_name in ['inventory_and_services', 'sales'] and ('item' in details or 'service_name' in details):
|
| 1099 |
item_name = details.get('item') or details.get('service_name')
|
| 1100 |
canonical_info = _get_canonical_info(user_phone, item_name)
|
|
@@ -1134,10 +1228,10 @@ def update_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
|
|
| 1134 |
continue
|
| 1135 |
target_doc = _find_document_by_details(user_phone, collection_name, details)
|
| 1136 |
if target_doc == "multiple_matches":
|
| 1137 |
-
feedback.append(f"Update for {trans_type} failed: Multiple records match. Please be more specific.")
|
| 1138 |
continue
|
| 1139 |
if not target_doc:
|
| 1140 |
-
feedback.append(f"Update for {trans_type} failed: No record found matching your description.")
|
| 1141 |
continue
|
| 1142 |
doc_id = target_doc["id"]
|
| 1143 |
doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(doc_id)
|
|
@@ -1171,10 +1265,10 @@ def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
|
|
| 1171 |
continue
|
| 1172 |
target_doc = _find_document_by_details(user_phone, collection_name, details)
|
| 1173 |
if target_doc == "multiple_matches":
|
| 1174 |
-
feedback.append(f"Delete for {trans_type} failed: Multiple records match.")
|
| 1175 |
continue
|
| 1176 |
if not target_doc:
|
| 1177 |
-
feedback.append(f"Delete for {trans_type} failed: No record found.")
|
| 1178 |
continue
|
| 1179 |
doc_id = target_doc["id"]
|
| 1180 |
try:
|
|
@@ -1186,6 +1280,34 @@ def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
|
|
| 1186 |
feedback.append(f"Delete for {trans_type} (ID: {doc_id}) failed with an error.")
|
| 1187 |
return any_success, "\n".join(feedback)
|
| 1188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1189 |
def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
|
| 1190 |
if not transactions: return False
|
| 1191 |
try:
|
|
@@ -1207,14 +1329,24 @@ def format_transaction_response(transactions: Union[List[Dict], Dict, None]) ->
|
|
| 1207 |
title = f"{trans_type}"
|
| 1208 |
if len(transactions) > 1: output_lines.append(f"--- {title} {idx + 1} ---")
|
| 1209 |
else: output_lines.append(f"--- {title} ---")
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
if 'transaction_id' in trans:
|
| 1213 |
-
|
| 1214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
for key in key_order:
|
| 1216 |
if key in details and key not in displayed_keys:
|
| 1217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1218 |
displayed_keys.add(key)
|
| 1219 |
for key, value in details.items():
|
| 1220 |
if key not in displayed_keys and key != 'currency':
|
|
@@ -1277,6 +1409,10 @@ def process_intent(parsed_trans_data: List[Dict], mobile: str) -> str:
|
|
| 1277 |
elif intent == 'delete':
|
| 1278 |
success, message = delete_transaction(mobile, transactions)
|
| 1279 |
final_feedback.append(f"Deletion Results:\n{message}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1280 |
else:
|
| 1281 |
final_feedback.append(f"Unknown intent '{intent}' for type '{trans_type}'.")
|
| 1282 |
except Exception as e:
|
|
|
|
| 120 |
logger.error(f"Error configuring Generative AI: {e}", exc_info=True)
|
| 121 |
model = vision_model = llm = None
|
| 122 |
|
| 123 |
+
# --- START: LANGUAGE PROCESSING FUNCTIONS (UPGRADE 4) ---
|
| 124 |
+
|
| 125 |
+
def detect_and_translate_input(text: str) -> Dict[str, str]:
|
| 126 |
+
"""
|
| 127 |
+
Detects the language of the input text.
|
| 128 |
+
If it's not English, translates it to English.
|
| 129 |
+
Returns: {'english_text': str, 'detected_lang': str}
|
| 130 |
+
"""
|
| 131 |
+
if not model:
|
| 132 |
+
return {'english_text': text, 'detected_lang': 'English'}
|
| 133 |
+
|
| 134 |
+
system_prompt = """
|
| 135 |
+
You are a language detection and translation engine for a bookkeeping bot.
|
| 136 |
+
1. Detect the language of the user's input (e.g., English, Shona, Zulu, Ndebele, Tswana, etc.).
|
| 137 |
+
2. If the language is NOT English, translate the text accurately to English, preserving business context (money, items, quantities).
|
| 138 |
+
3. If the language IS English, return the text as is.
|
| 139 |
+
4. Return ONLY a valid JSON object.
|
| 140 |
+
|
| 141 |
+
Output Schema:
|
| 142 |
+
{
|
| 143 |
+
"detected_lang": "Name of Language",
|
| 144 |
+
"english_text": "The translated text or original text"
|
| 145 |
+
}
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
response = model.generate_content([system_prompt, text])
|
| 149 |
+
response_text = response.text
|
| 150 |
+
cleaned_response = re.sub(r'^```json\s*|\s*```$', '', response_text, flags=re.MULTILINE).strip()
|
| 151 |
+
result = json.loads(cleaned_response)
|
| 152 |
+
return result
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"Language detection failed: {e}")
|
| 155 |
+
# Fallback to assuming English if AI fails
|
| 156 |
+
return {'english_text': text, 'detected_lang': 'English'}
|
| 157 |
+
|
| 158 |
+
def translate_output(text: str, target_lang: str) -> str:
|
| 159 |
+
"""
|
| 160 |
+
Translates the system's English response back to the target language.
|
| 161 |
+
"""
|
| 162 |
+
if not model or target_lang.lower() == 'english':
|
| 163 |
+
return text
|
| 164 |
+
|
| 165 |
+
prompt = f"""
|
| 166 |
+
Translate the following bookkeeping response from English to {target_lang}.
|
| 167 |
+
Keep numbers, currency symbols (like $ or R), and proper nouns (like item names) unchanged.
|
| 168 |
+
Maintain a professional but helpful tone.
|
| 169 |
+
|
| 170 |
+
Text to translate:
|
| 171 |
+
"{text}"
|
| 172 |
+
"""
|
| 173 |
+
try:
|
| 174 |
+
response = model.generate_content(prompt)
|
| 175 |
+
return response.text.strip()
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"Output translation failed: {e}")
|
| 178 |
+
return text
|
| 179 |
+
|
| 180 |
+
# --- END: LANGUAGE PROCESSING FUNCTIONS ---
|
| 181 |
+
|
| 182 |
# --- START: VISION PROCESSING FUNCTIONS ---
|
| 183 |
|
| 184 |
def _transpile_vision_json_to_query(vision_json: List[Dict]) -> str:
|
|
|
|
| 224 |
try:
|
| 225 |
image_pil = Image.open(io.BytesIO(image_bytes))
|
| 226 |
|
|
|
|
| 227 |
prompt = f"""
|
| 228 |
You are a bookkeeping vision model. Analyze the image. Return ONLY a valid JSON array [] of transaction objects.
|
| 229 |
|
|
|
|
| 263 |
- liability: creditor, amount, currency
|
| 264 |
- query: query (verbatim text)
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
Analyze the provided image and return only the JSON list.
|
| 267 |
"""
|
| 268 |
|
|
|
|
| 387 |
num_transactions = len(target_df)
|
| 388 |
item_summary = target_df.groupby('item')['quantity'].sum()
|
| 389 |
|
|
|
|
| 390 |
if item_summary.empty:
|
| 391 |
best_selling_item = "N/A"
|
| 392 |
worst_selling_item = "N/A"
|
|
|
|
| 583 |
|
| 584 |
return snapshot
|
| 585 |
|
|
|
|
| 586 |
def generateResponse(prompt: str, currency: str = "R") -> str:
|
| 587 |
"""Generate structured JSON response from user input using Generative AI."""
|
| 588 |
if not model:
|
| 589 |
return '{"error": "Model not available"}'
|
| 590 |
|
| 591 |
+
# --- UPDATED: System Prompt for Upgrades 1, 2, 3, 5 ---
|
|
|
|
| 592 |
system_prompt = f"""
|
| 593 |
Analyze the user's request for business transaction management. Your goal is to extract structured information about one or more transactions and output it as a valid JSON list.
|
| 594 |
|
|
|
|
| 600 |
|
| 601 |
**2. Transaction Object Structure:**
|
| 602 |
Each transaction object MUST have the following keys:
|
| 603 |
+
- `"intent"`: The user's goal ("create", "read", "update", "delete", "reset_account").
|
| 604 |
+
- `"transaction_type"`: The category of the transaction (e.g., "sale", "purchase", "inventory", "expense", "asset", "liability", "query", "service_offering", "account").
|
| 605 |
- `"details"`: An object containing key-value pairs extracted from the request.
|
| 606 |
|
| 607 |
**3. Key Naming Conventions for the `details` Object:**
|
| 608 |
- **For Expenses:** Use `"amount"`, `"description"`, and `"category"`.
|
| 609 |
- **For Assets:** Use `"value"` for the monetary worth and `"name"` for the item's name.
|
| 610 |
- **For Liabilities:** Use `"amount"` and `"creditor"`.
|
| 611 |
+
- **For Sales/Inventory (Updated):**
|
| 612 |
+
- Use `"item"`, `"quantity"`, and `"price"`.
|
| 613 |
+
- **Customer:** If a customer name is mentioned (e.g., "Sold to John"), extract as `"customer"`.
|
| 614 |
+
- **Payment:** If the user mentions how much was paid (e.g., "They gave me $10" or "paid 500"), extract as `"amount_paid"`.
|
| 615 |
+
- **For Updates/Deletes (Updated):**
|
| 616 |
+
- If a Transaction ID is mentioned (e.g., "update transaction 8f3a..."), extract it as `"transaction_id"`.
|
| 617 |
- **For all financial transactions:** Always include a `"currency"` key. Use user input if available, else default to {currency}.
|
| 618 |
|
| 619 |
+
**4. Special Intents:**
|
| 620 |
+
- **Account Reset:** If the user explicitly asks to "reset my account", "wipe data", or "delete all data", use intent `"reset_account"` and transaction_type `"account"`.
|
|
|
|
| 621 |
|
| 622 |
**5. Examples:**
|
| 623 |
|
| 624 |
+
**Example 1: Sales with Change**
|
| 625 |
+
- **Input:** "Sold 2 bread for $1 each, customer gave me $5"
|
| 626 |
+
- **Output:** [ {{"intent": "create", "transaction_type": "sale", "details": {{"item": "bread", "quantity": 2, "price": 1, "amount_paid": 5, "currency": "$"}} }} ]
|
| 627 |
+
|
| 628 |
+
**Example 2: Update with ID**
|
| 629 |
+
- **Input:** "Update transaction abc1234, change price to 50"
|
| 630 |
+
- **Output:** [ {{"intent": "update", "transaction_type": "sale", "details": {{"transaction_id": "abc1234", "price": 50}} }} ]
|
| 631 |
|
| 632 |
+
**Example 3: Reset Account**
|
| 633 |
+
- **Input:** "Reset my account completely"
|
| 634 |
+
- **Output:** [ {{"intent": "reset_account", "transaction_type": "account", "details": {{}} }} ]
|
| 635 |
"""
|
| 636 |
try:
|
| 637 |
full_prompt = [system_prompt, prompt]
|
|
|
|
| 672 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory_and_services")
|
| 673 |
name_lower = item_name.lower().strip()
|
| 674 |
|
|
|
|
|
|
|
| 675 |
if name_lower.endswith('ss'):
|
| 676 |
singular = name_lower
|
| 677 |
else:
|
|
|
|
| 758 |
all_sales_docs.sort(key=lambda doc: doc.to_dict().get('timestamp', ''), reverse=True)
|
| 759 |
last_sale_data = all_sales_docs[0].to_dict()
|
| 760 |
last_selling_price = last_sale_data.get('details', {}).get('price')
|
| 761 |
+
|
| 762 |
@firestore.transactional
|
| 763 |
def process_one_sale(transaction, sale_details):
|
| 764 |
is_new_item = canonical_info['doc'] is None
|
| 765 |
original_trans_type = t.get('transaction_type')
|
| 766 |
item_type = 'service' if original_trans_type == 'service_offering' else 'good'
|
| 767 |
user_price = sale_details.get('price') or sale_details.get('unit_price')
|
| 768 |
+
|
| 769 |
if user_price is not None:
|
| 770 |
selling_price = user_price
|
| 771 |
elif last_selling_price is not None:
|
|
|
|
| 776 |
else:
|
| 777 |
selling_price = 0
|
| 778 |
if not isinstance(selling_price, (int, float)): selling_price = 0
|
| 779 |
+
|
| 780 |
sale_details['price'] = selling_price
|
| 781 |
sale_details['item'] = canonical_name
|
| 782 |
if 'unit_price' in sale_details: del sale_details['unit_price']
|
| 783 |
if 'service_name' in sale_details: del sale_details['service_name']
|
| 784 |
+
|
| 785 |
try:
|
| 786 |
quantity_sold = int(sale_details.get('quantity', 1))
|
| 787 |
if quantity_sold <= 0: return f"Sale failed for '{canonical_name}': Invalid quantity ({quantity_sold})."
|
| 788 |
except (ValueError, TypeError):
|
| 789 |
return f"Sale failed for '{canonical_name}': Invalid quantity format."
|
| 790 |
+
|
| 791 |
+
# --- UPGRADE 5: Debt and Change Logic ---
|
| 792 |
+
total_due = selling_price * quantity_sold
|
| 793 |
+
amount_paid = sale_details.get('amount_paid')
|
| 794 |
+
change_due = 0
|
| 795 |
+
amount_outstanding = 0
|
| 796 |
+
payment_status = 'completed'
|
| 797 |
+
|
| 798 |
+
if amount_paid is not None:
|
| 799 |
+
try:
|
| 800 |
+
amount_paid = float(amount_paid)
|
| 801 |
+
if amount_paid >= total_due:
|
| 802 |
+
change_due = amount_paid - total_due
|
| 803 |
+
sale_details['change_given'] = change_due
|
| 804 |
+
payment_status = 'paid_in_full'
|
| 805 |
+
else:
|
| 806 |
+
amount_outstanding = total_due - amount_paid
|
| 807 |
+
sale_details['amount_outstanding'] = amount_outstanding
|
| 808 |
+
payment_status = 'partial_payment'
|
| 809 |
+
except ValueError:
|
| 810 |
+
pass # Ignore if amount_paid is not a valid number
|
| 811 |
+
|
| 812 |
+
# Check Credit/Debt if no payment info but 'customer' exists (implicitly credit?)
|
| 813 |
+
# For now, if no amount_paid is sent, we assume it's a cash sale (paid in full) unless otherwise specified.
|
| 814 |
+
|
| 815 |
+
sale_details['status'] = payment_status
|
| 816 |
+
# -------------------------------------
|
| 817 |
+
|
| 818 |
item_doc_ref = db.collection("users").document(user_phone).collection("inventory_and_services").document(canonical_name)
|
| 819 |
item_snapshot = item_doc_ref.get(transaction=transaction)
|
| 820 |
item_cost = 0
|
|
|
|
| 838 |
'last_updated': datetime.now(timezone.utc).isoformat()
|
| 839 |
}
|
| 840 |
transaction.set(item_doc_ref, service_record)
|
| 841 |
+
|
| 842 |
sale_doc_ref = sales_ref.document()
|
| 843 |
sale_record = {
|
| 844 |
'details': {**sale_details, 'cost': item_cost},
|
| 845 |
'timestamp': datetime.now(timezone.utc).isoformat(),
|
| 846 |
+
'status': payment_status,
|
| 847 |
'transaction_id': sale_doc_ref.id
|
| 848 |
}
|
| 849 |
transaction.set(sale_doc_ref, sale_record)
|
| 850 |
+
|
| 851 |
+
msg = f"Sale successful for {quantity_sold} x '{canonical_name}' at {sale_details.get('currency','')}{selling_price} each."
|
| 852 |
+
if change_due > 0:
|
| 853 |
+
msg += f" Change due: {sale_details.get('currency','')}{change_due:.2f}."
|
| 854 |
+
if amount_outstanding > 0:
|
| 855 |
+
msg += f" Outstanding debt: {sale_details.get('currency','')}{amount_outstanding:.2f}."
|
| 856 |
+
return msg
|
| 857 |
+
|
| 858 |
transaction_feedback = process_one_sale(db.transaction(), details)
|
| 859 |
feedback_messages.append(transaction_feedback)
|
| 860 |
if "successful" in transaction_feedback:
|
|
|
|
| 1183 |
|
| 1184 |
def _find_document_by_details(user_phone: str, collection_name: str, details: Dict) -> Optional[Any]:
|
| 1185 |
col_ref = db.collection("users").document(user_phone).collection(collection_name)
|
| 1186 |
+
|
| 1187 |
+
# --- UPGRADE 1 & 2: Search by Transaction ID ---
|
| 1188 |
if 'transaction_id' in details and details['transaction_id']:
|
| 1189 |
doc = col_ref.document(details['transaction_id']).get()
|
| 1190 |
if doc.exists: return {"id": doc.id, "data": doc.to_dict()}
|
| 1191 |
+
|
| 1192 |
if collection_name in ['inventory_and_services', 'sales'] and ('item' in details or 'service_name' in details):
|
| 1193 |
item_name = details.get('item') or details.get('service_name')
|
| 1194 |
canonical_info = _get_canonical_info(user_phone, item_name)
|
|
|
|
| 1228 |
continue
|
| 1229 |
target_doc = _find_document_by_details(user_phone, collection_name, details)
|
| 1230 |
if target_doc == "multiple_matches":
|
| 1231 |
+
feedback.append(f"Update for {trans_type} failed: Multiple records match. Please be more specific or use the ID.")
|
| 1232 |
continue
|
| 1233 |
if not target_doc:
|
| 1234 |
+
feedback.append(f"Update for {trans_type} failed: No record found matching your description or ID.")
|
| 1235 |
continue
|
| 1236 |
doc_id = target_doc["id"]
|
| 1237 |
doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(doc_id)
|
|
|
|
| 1265 |
continue
|
| 1266 |
target_doc = _find_document_by_details(user_phone, collection_name, details)
|
| 1267 |
if target_doc == "multiple_matches":
|
| 1268 |
+
feedback.append(f"Delete for {trans_type} failed: Multiple records match. Please be more specific or use the ID.")
|
| 1269 |
continue
|
| 1270 |
if not target_doc:
|
| 1271 |
+
feedback.append(f"Delete for {trans_type} failed: No record found matching your description or ID.")
|
| 1272 |
continue
|
| 1273 |
doc_id = target_doc["id"]
|
| 1274 |
try:
|
|
|
|
| 1280 |
feedback.append(f"Delete for {trans_type} (ID: {doc_id}) failed with an error.")
|
| 1281 |
return any_success, "\n".join(feedback)
|
| 1282 |
|
| 1283 |
+
def reset_user_account(user_phone: str) -> str:
|
| 1284 |
+
"""Permanently deletes all transaction data for a user. (Upgrade 3)"""
|
| 1285 |
+
collections = ['sales', 'expenses', 'assets', 'liabilities', 'inventory_and_services', 'temp_transactions']
|
| 1286 |
+
user_ref = db.collection("users").document(user_phone)
|
| 1287 |
+
|
| 1288 |
+
try:
|
| 1289 |
+
total_deleted = 0
|
| 1290 |
+
batch = db.batch()
|
| 1291 |
+
count = 0
|
| 1292 |
+
|
| 1293 |
+
for coll_name in collections:
|
| 1294 |
+
docs = user_ref.collection(coll_name).list_documents()
|
| 1295 |
+
for doc in docs:
|
| 1296 |
+
batch.delete(doc)
|
| 1297 |
+
count += 1
|
| 1298 |
+
if count >= 450: # Firestore batch limit
|
| 1299 |
+
batch.commit()
|
| 1300 |
+
batch = db.batch()
|
| 1301 |
+
count = 0
|
| 1302 |
+
|
| 1303 |
+
if count > 0:
|
| 1304 |
+
batch.commit()
|
| 1305 |
+
|
| 1306 |
+
return "Your account has been reset. All transaction data has been permanently deleted."
|
| 1307 |
+
except Exception as e:
|
| 1308 |
+
logger.error(f"Account reset failed for {user_phone}: {e}", exc_info=True)
|
| 1309 |
+
return "An error occurred while resetting your account. Please try again later."
|
| 1310 |
+
|
| 1311 |
def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
|
| 1312 |
if not transactions: return False
|
| 1313 |
try:
|
|
|
|
| 1329 |
title = f"{trans_type}"
|
| 1330 |
if len(transactions) > 1: output_lines.append(f"--- {title} {idx + 1} ---")
|
| 1331 |
else: output_lines.append(f"--- {title} ---")
|
| 1332 |
+
|
| 1333 |
+
# --- UPGRADE 1 & 2: Make Transaction ID easy to copy ---
|
| 1334 |
if 'transaction_id' in trans:
|
| 1335 |
+
# Markdown code block for easy copying on WhatsApp
|
| 1336 |
+
output_lines.append(f"• Transaction ID: ```{trans['transaction_id']}```")
|
| 1337 |
+
|
| 1338 |
+
key_order = ['item', 'service_name', 'name', 'creditor', 'category', 'quantity', 'units_available', 'hours', 'price', 'rate', 'amount', 'cost', 'value', 'customer', 'amount_paid', 'change_given', 'amount_outstanding', 'vendor', 'client', 'date', 'acquisition_date', 'due_date', 'description', 'type']
|
| 1339 |
+
|
| 1340 |
+
displayed_keys = set()
|
| 1341 |
+
displayed_keys.add('transaction_id') # Already displayed
|
| 1342 |
+
|
| 1343 |
for key in key_order:
|
| 1344 |
if key in details and key not in displayed_keys:
|
| 1345 |
+
val = details[key]
|
| 1346 |
+
# Format money fields if generic
|
| 1347 |
+
if isinstance(val, (int, float)) and key in ['price', 'amount', 'cost', 'value', 'amount_paid', 'change_given', 'amount_outstanding']:
|
| 1348 |
+
val = f"{details.get('currency', '')}{val:.2f}"
|
| 1349 |
+
output_lines.append(f"• {key.replace('_', ' ').title()}: {val}")
|
| 1350 |
displayed_keys.add(key)
|
| 1351 |
for key, value in details.items():
|
| 1352 |
if key not in displayed_keys and key != 'currency':
|
|
|
|
| 1409 |
elif intent == 'delete':
|
| 1410 |
success, message = delete_transaction(mobile, transactions)
|
| 1411 |
final_feedback.append(f"Deletion Results:\n{message}")
|
| 1412 |
+
elif intent == 'reset_account':
|
| 1413 |
+
# --- UPGRADE 3: Reset Account Handler ---
|
| 1414 |
+
message = reset_user_account(mobile)
|
| 1415 |
+
final_feedback.append(message)
|
| 1416 |
else:
|
| 1417 |
final_feedback.append(f"Unknown intent '{intent}' for type '{trans_type}'.")
|
| 1418 |
except Exception as e:
|