From c8fad1cf3693812861718a87b96aa26cc75742fb Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 12 Jul 2026 12:16:04 +0000 Subject: [PATCH] Add .gitignore, remove __pycache__ from tracking Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 + backend/api/__init__.py | 3 - .../api/__pycache__/__init__.cpython-313.pyc | Bin 245 -> 0 bytes .../api/__pycache__/backup.cpython-313.pyc | Bin 16160 -> 0 bytes .../api/__pycache__/budget.cpython-313.pyc | Bin 51245 -> 0 bytes .../calendar_events.cpython-313.pyc | Bin 10330 -> 0 bytes .../cost_distributions.cpython-313.pyc | Bin 39855 -> 0 bytes .../cover_overrides.cpython-313.pyc | Bin 30317 -> 0 bytes .../__pycache__/credit_notes.cpython-313.pyc | Bin 12744 -> 0 bytes .../api/__pycache__/disputes.cpython-313.pyc | Bin 53955 -> 0 bytes .../__pycache__/event_orders.cpython-313.pyc | Bin 35952 -> 0 bytes .../api/__pycache__/external.cpython-313.pyc | Bin 26501 -> 0 bytes .../field_mappings.cpython-313.pyc | Bin 8821 -> 0 bytes .../__pycache__/food_flags.cpython-313.pyc | Bin 113833 -> 0 bytes backend/api/__pycache__/imap.cpython-313.pyc | Bin 15526 -> 0 bytes .../__pycache__/ingredients.cpython-313.pyc | Bin 98134 -> 0 bytes .../api/__pycache__/invoices.cpython-313.pyc | Bin 173552 -> 0 bytes backend/api/__pycache__/kds.cpython-313.pyc | Bin 60206 -> 0 bytes .../api/__pycache__/logbook.cpython-313.pyc | Bin 32862 -> 0 bytes backend/api/__pycache__/menus.cpython-313.pyc | Bin 66762 -> 0 bytes .../api/__pycache__/newbook.cpython-313.pyc | Bin 46976 -> 0 bytes .../api/__pycache__/public.cpython-313.pyc | Bin 5012 -> 0 bytes .../purchase_orders.cpython-313.pyc | Bin 42462 -> 0 bytes .../api/__pycache__/recipes.cpython-313.pyc | Bin 120951 -> 0 bytes .../api/__pycache__/reports.cpython-313.pyc | Bin 131520 -> 0 bytes .../residents_table_chart.cpython-313.pyc | Bin 16486 -> 0 bytes backend/api/__pycache__/resos.cpython-313.pyc | Bin 38301 -> 0 bytes .../api/__pycache__/sambapos.cpython-313.pyc | Bin 29982 -> 0 bytes .../api/__pycache__/search.cpython-313.pyc | Bin 33011 -> 0 bytes .../api/__pycache__/settings.cpython-313.pyc | Bin 34743 -> 0 bytes .../api/__pycache__/suppliers.cpython-313.pyc | Bin 15099 -> 0 bytes .../api/__pycache__/support.cpython-313.pyc | Bin 8573 -> 0 bytes backend/api/backup.py | 374 -- backend/api/budget.py | 1121 ----- backend/api/calendar_events.py | 236 - backend/api/cost_distributions.py | 863 ---- backend/api/cover_overrides.py | 648 --- backend/api/credit_notes.py | 327 -- backend/api/disputes.py | 1125 ----- backend/api/event_orders.py | 694 --- backend/api/external.py | 497 -- backend/api/field_mappings.py | 205 - backend/api/food_flags.py | 2309 ---------- backend/api/imap.py | 341 -- backend/api/ingredients.py | 2013 -------- backend/api/internal.py | 64 - backend/api/invoices.py | 4077 ----------------- backend/api/logbook.py | 745 --- backend/api/menus.py | 1308 ------ backend/api/newbook.py | 1016 ---- backend/api/public.py | 118 - backend/api/purchase_orders.py | 835 ---- backend/api/recipes.py | 2318 ---------- backend/api/reconciliation.py | 1187 ----- backend/api/reports.py | 3421 -------------- backend/api/residents_table_chart.py | 422 -- backend/api/resos.py | 809 ---- backend/api/sambapos.py | 690 --- backend/api/search.py | 744 --- backend/api/settings.py | 798 ---- backend/api/suppliers.py | 343 -- backend/api/support.py | 204 - .../__pycache__/__init__.cpython-313.pyc | Bin 119 -> 0 bytes ...add_admin_restricted_pages.cpython-313.pyc | Bin 2202 -> 0 bytes .../add_allergen_keywords.cpython-313.pyc | Bin 9880 -> 0 bytes ...waiting_replacement_status.cpython-313.pyc | Bin 1931 -> 0 bytes .../add_brakes_cache.cpython-313.pyc | Bin 1787 -> 0 bytes .../add_brakes_dietary_info.cpython-313.pyc | Bin 1269 -> 0 bytes .../add_budget_settings.cpython-313.pyc | Bin 4001 -> 0 bytes .../add_calendar_events.cpython-313.pyc | Bin 2384 -> 0 bytes ...add_changelog_invoice_link.cpython-313.pyc | Bin 1469 -> 0 bytes .../add_cost_distributions.cpython-313.pyc | Bin 6725 -> 0 bytes .../add_cover_overrides.cpython-313.pyc | Bin 6009 -> 0 bytes .../add_description_aliases.cpython-313.pyc | Bin 4045 -> 0 bytes .../add_dext_integration.cpython-313.pyc | Bin 16184 -> 0 bytes .../add_dext_manual_send.cpython-313.pyc | Bin 1726 -> 0 bytes .../add_dish_separation.cpython-313.pyc | Bin 2171 -> 0 bytes ...ute_attachment_public_hash.cpython-313.pyc | Bin 2339 -> 0 bytes .../add_flag_assessment.cpython-313.pyc | Bin 2504 -> 0 bytes .../add_gross_sell_price.cpython-313.pyc | Bin 1603 -> 0 bytes .../add_imap_integration.cpython-313.pyc | Bin 4649 -> 0 bytes ...ingredient_flag_dismissals.cpython-313.pyc | Bin 2044 -> 0 bytes .../add_ingredient_is_free.cpython-313.pyc | Bin 1291 -> 0 bytes .../add_invoice_disputes.cpython-313.pyc | Bin 3326 -> 0 bytes .../add_invoice_features.cpython-313.pyc | Bin 32184 -> 0 bytes .../add_kds_bookings_refresh.cpython-313.pyc | Bin 1816 -> 0 bytes .../add_kds_course_flow.cpython-313.pyc | Bin 2251 -> 0 bytes .../add_kds_order_tracking.cpython-313.pyc | Bin 1848 -> 0 bytes .../add_kds_tables.cpython-313.pyc | Bin 4887 -> 0 bytes .../add_kitchen_details.cpython-313.pyc | Bin 2027 -> 0 bytes .../add_line_item_search.cpython-313.pyc | Bin 2744 -> 0 bytes .../add_linked_dispute.cpython-313.pyc | Bin 1965 -> 0 bytes .../add_llm_infrastructure.cpython-313.pyc | Bin 4937 -> 0 bytes .../__pycache__/add_logbook.cpython-313.pyc | Bin 2321 -> 0 bytes .../__pycache__/add_menus.cpython-313.pyc | Bin 4324 -> 0 bytes .../add_new_status.cpython-313.pyc | Bin 2907 -> 0 bytes ...d_newbook_arrival_tracking.cpython-313.pyc | Bin 3016 -> 0 bytes .../add_newbook_tables.cpython-313.pyc | Bin 12036 -> 0 bytes .../add_newbook_upcoming_sync.cpython-313.pyc | Bin 1946 -> 0 bytes .../add_nextcloud_backup.cpython-313.pyc | Bin 8003 -> 0 bytes .../add_ocr_post_processing.cpython-313.pyc | Bin 2467 -> 0 bytes .../add_ocr_weight_setting.cpython-313.pyc | Bin 1731 -> 0 bytes ...dd_pdf_annotation_settings.cpython-313.pyc | Bin 2439 -> 0 bytes .../add_price_settings.cpython-313.pyc | Bin 5504 -> 0 bytes .../add_purchase_orders.cpython-313.pyc | Bin 4597 -> 0 bytes .../add_recipe_batch_type.cpython-313.pyc | Bin 2062 -> 0 bytes ...add_recipe_ingredient_unit.cpython-313.pyc | Bin 1631 -> 0 bytes ...dd_recipe_ingredient_yield.cpython-313.pyc | Bin 2092 -> 0 bytes .../add_recipe_system.cpython-313.pyc | Bin 32113 -> 0 bytes ...ecipe_text_flag_dismissals.cpython-313.pyc | Bin 2323 -> 0 bytes .../add_residents_table_chart.cpython-313.pyc | Bin 2749 -> 0 bytes ...esos_arrival_widget_filter.cpython-313.pyc | Bin 2665 -> 0 bytes .../add_resos_integration.cpython-313.pyc | Bin 14233 -> 0 bytes ...add_resos_manual_breakfast.cpython-313.pyc | Bin 3124 -> 0 bytes .../add_resos_upcoming_sync.cpython-313.pyc | Bin 1928 -> 0 bytes .../add_rooms_breakdown.cpython-313.pyc | Bin 2003 -> 0 bytes ...dd_sambapos_excluded_items.cpython-313.pyc | Bin 2213 -> 0 bytes .../add_sambapos_portion_name.cpython-313.pyc | Bin 1633 -> 0 bytes .../add_sambapos_settings.cpython-313.pyc | Bin 2766 -> 0 bytes .../add_step_title.cpython-313.pyc | Bin 1566 -> 0 bytes .../add_sub_recipe_unit.cpython-313.pyc | Bin 1672 -> 0 bytes .../add_supplier_po_fields.cpython-313.pyc | Bin 2321 -> 0 bytes .../add_supplier_skip_dext.cpython-313.pyc | Bin 1685 -> 0 bytes .../add_support_request.cpython-313.pyc | Bin 1647 -> 0 bytes .../add_unique_flag_types.cpython-313.pyc | Bin 2261 -> 0 bytes .../add_vat_rate_settings.cpython-313.pyc | Bin 3713 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 2178 -> 0 bytes .../acknowledged_price.cpython-313.pyc | Bin 2834 -> 0 bytes .../models/__pycache__/backup.cpython-313.pyc | Bin 2403 -> 0 bytes .../calendar_events.cpython-313.pyc | Bin 1944 -> 0 bytes .../cost_distribution.cpython-313.pyc | Bin 6674 -> 0 bytes .../cover_override.cpython-313.pyc | Bin 4703 -> 0 bytes .../__pycache__/dispute.cpython-313.pyc | Bin 13958 -> 0 bytes .../email_processing.cpython-313.pyc | Bin 2578 -> 0 bytes .../__pycache__/event_order.cpython-313.pyc | Bin 3282 -> 0 bytes .../__pycache__/field_mapping.cpython-313.pyc | Bin 3009 -> 0 bytes .../__pycache__/food_flag.cpython-313.pyc | Bin 9222 -> 0 bytes backend/models/__pycache__/gp.cpython-313.pyc | Bin 2918 -> 0 bytes .../__pycache__/ingredient.cpython-313.pyc | Bin 10776 -> 0 bytes .../__pycache__/invoice.cpython-313.pyc | Bin 5952 -> 0 bytes .../models/__pycache__/kds.cpython-313.pyc | Bin 4361 -> 0 bytes .../__pycache__/line_item.cpython-313.pyc | Bin 3429 -> 0 bytes .../models/__pycache__/llm.cpython-313.pyc | Bin 3037 -> 0 bytes .../__pycache__/logbook.cpython-313.pyc | Bin 7298 -> 0 bytes .../models/__pycache__/menu.cpython-313.pyc | Bin 5509 -> 0 bytes .../__pycache__/newbook.cpython-313.pyc | Bin 8979 -> 0 bytes .../product_definition.cpython-313.pyc | Bin 3191 -> 0 bytes .../purchase_order.cpython-313.pyc | Bin 5328 -> 0 bytes .../models/__pycache__/recipe.cpython-313.pyc | Bin 13987 -> 0 bytes .../models/__pycache__/resos.cpython-313.pyc | Bin 7244 -> 0 bytes .../__pycache__/settings.cpython-313.pyc | Bin 14731 -> 0 bytes .../__pycache__/supplier.cpython-313.pyc | Bin 2268 -> 0 bytes .../models/__pycache__/user.cpython-313.pyc | Bin 4538 -> 0 bytes .../ocr/__pycache__/__init__.cpython-313.pyc | Bin 262 -> 0 bytes .../azure_extractor.cpython-313.pyc | Bin 36213 -> 0 bytes .../ocr/__pycache__/extractor.cpython-313.pyc | Bin 6721 -> 0 bytes .../ocr/__pycache__/parser.cpython-313.pyc | Bin 13411 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 249 -> 0 bytes .../backup_service.cpython-313.pyc | Bin 37326 -> 0 bytes .../brakes_scraper.cpython-313.pyc | Bin 7267 -> 0 bytes .../dispute_archival_service.cpython-313.pyc | Bin 21247 -> 0 bytes .../duplicate_detector.cpython-313.pyc | Bin 10706 -> 0 bytes .../__pycache__/email_service.cpython-313.pyc | Bin 14887 -> 0 bytes .../file_archival_service.cpython-313.pyc | Bin 18181 -> 0 bytes .../__pycache__/forecast_api.cpython-313.pyc | Bin 13736 -> 0 bytes .../__pycache__/imap_sync.cpython-313.pyc | Bin 25396 -> 0 bytes .../__pycache__/kds_graphql.cpython-313.pyc | Bin 14726 -> 0 bytes .../__pycache__/llm_service.cpython-313.pyc | Bin 58622 -> 0 bytes .../__pycache__/newbook_api.cpython-313.pyc | Bin 41175 -> 0 bytes .../__pycache__/newbook_sync.cpython-313.pyc | Bin 35457 -> 0 bytes .../nextcloud_service.cpython-313.pyc | Bin 17480 -> 0 bytes .../pdf_highlighter.cpython-313.pyc | Bin 37242 -> 0 bytes .../__pycache__/pdf_rotation.cpython-313.pyc | Bin 12156 -> 0 bytes .../__pycache__/po_matching.cpython-313.pyc | Bin 6690 -> 0 bytes .../__pycache__/price_history.cpython-313.pyc | Bin 32891 -> 0 bytes .../__pycache__/resos_api.cpython-313.pyc | Bin 7697 -> 0 bytes .../__pycache__/resos_stats.cpython-313.pyc | Bin 44280 -> 0 bytes .../__pycache__/resos_sync.cpython-313.pyc | Bin 39916 -> 0 bytes .../__pycache__/sambapos_api.cpython-313.pyc | Bin 53340 -> 0 bytes .../signalr_listener.cpython-313.pyc | Bin 16694 -> 0 bytes .../__pycache__/stock_history.cpython-313.pyc | Bin 7131 -> 0 bytes 181 files changed, 8 insertions(+), 29855 deletions(-) create mode 100644 .gitignore delete mode 100644 backend/api/__init__.py delete mode 100644 backend/api/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/api/__pycache__/backup.cpython-313.pyc delete mode 100644 backend/api/__pycache__/budget.cpython-313.pyc delete mode 100644 backend/api/__pycache__/calendar_events.cpython-313.pyc delete mode 100644 backend/api/__pycache__/cost_distributions.cpython-313.pyc delete mode 100644 backend/api/__pycache__/cover_overrides.cpython-313.pyc delete mode 100644 backend/api/__pycache__/credit_notes.cpython-313.pyc delete mode 100644 backend/api/__pycache__/disputes.cpython-313.pyc delete mode 100644 backend/api/__pycache__/event_orders.cpython-313.pyc delete mode 100644 backend/api/__pycache__/external.cpython-313.pyc delete mode 100644 backend/api/__pycache__/field_mappings.cpython-313.pyc delete mode 100644 backend/api/__pycache__/food_flags.cpython-313.pyc delete mode 100644 backend/api/__pycache__/imap.cpython-313.pyc delete mode 100644 backend/api/__pycache__/ingredients.cpython-313.pyc delete mode 100644 backend/api/__pycache__/invoices.cpython-313.pyc delete mode 100644 backend/api/__pycache__/kds.cpython-313.pyc delete mode 100644 backend/api/__pycache__/logbook.cpython-313.pyc delete mode 100644 backend/api/__pycache__/menus.cpython-313.pyc delete mode 100644 backend/api/__pycache__/newbook.cpython-313.pyc delete mode 100644 backend/api/__pycache__/public.cpython-313.pyc delete mode 100644 backend/api/__pycache__/purchase_orders.cpython-313.pyc delete mode 100644 backend/api/__pycache__/recipes.cpython-313.pyc delete mode 100644 backend/api/__pycache__/reports.cpython-313.pyc delete mode 100644 backend/api/__pycache__/residents_table_chart.cpython-313.pyc delete mode 100644 backend/api/__pycache__/resos.cpython-313.pyc delete mode 100644 backend/api/__pycache__/sambapos.cpython-313.pyc delete mode 100644 backend/api/__pycache__/search.cpython-313.pyc delete mode 100644 backend/api/__pycache__/settings.cpython-313.pyc delete mode 100644 backend/api/__pycache__/suppliers.cpython-313.pyc delete mode 100644 backend/api/__pycache__/support.cpython-313.pyc delete mode 100644 backend/api/backup.py delete mode 100644 backend/api/budget.py delete mode 100644 backend/api/calendar_events.py delete mode 100644 backend/api/cost_distributions.py delete mode 100644 backend/api/cover_overrides.py delete mode 100644 backend/api/credit_notes.py delete mode 100644 backend/api/disputes.py delete mode 100644 backend/api/event_orders.py delete mode 100644 backend/api/external.py delete mode 100644 backend/api/field_mappings.py delete mode 100644 backend/api/food_flags.py delete mode 100644 backend/api/imap.py delete mode 100644 backend/api/ingredients.py delete mode 100644 backend/api/internal.py delete mode 100644 backend/api/invoices.py delete mode 100644 backend/api/logbook.py delete mode 100644 backend/api/menus.py delete mode 100644 backend/api/newbook.py delete mode 100644 backend/api/public.py delete mode 100644 backend/api/purchase_orders.py delete mode 100644 backend/api/recipes.py delete mode 100644 backend/api/reconciliation.py delete mode 100644 backend/api/reports.py delete mode 100644 backend/api/residents_table_chart.py delete mode 100644 backend/api/resos.py delete mode 100644 backend/api/sambapos.py delete mode 100644 backend/api/search.py delete mode 100644 backend/api/settings.py delete mode 100644 backend/api/suppliers.py delete mode 100644 backend/api/support.py delete mode 100644 backend/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_allergen_keywords.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_brakes_dietary_info.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_budget_settings.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_calendar_events.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_dish_separation.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_flag_assessment.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_ingredient_flag_dismissals.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_ingredient_is_free.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_invoice_disputes.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_invoice_features.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_kds_course_flow.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_line_item_search.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_llm_infrastructure.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_logbook.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_menus.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_new_status.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_newbook_arrival_tracking.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_pdf_annotation_settings.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_price_settings.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_recipe_ingredient_unit.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_recipe_text_flag_dismissals.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_residents_table_chart.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_resos_arrival_widget_filter.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_resos_manual_breakfast.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_rooms_breakdown.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_sambapos_settings.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_step_title.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_supplier_po_fields.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_support_request.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc delete mode 100644 backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc delete mode 100644 backend/models/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/models/__pycache__/acknowledged_price.cpython-313.pyc delete mode 100644 backend/models/__pycache__/backup.cpython-313.pyc delete mode 100644 backend/models/__pycache__/calendar_events.cpython-313.pyc delete mode 100644 backend/models/__pycache__/cost_distribution.cpython-313.pyc delete mode 100644 backend/models/__pycache__/cover_override.cpython-313.pyc delete mode 100644 backend/models/__pycache__/dispute.cpython-313.pyc delete mode 100644 backend/models/__pycache__/email_processing.cpython-313.pyc delete mode 100644 backend/models/__pycache__/event_order.cpython-313.pyc delete mode 100644 backend/models/__pycache__/field_mapping.cpython-313.pyc delete mode 100644 backend/models/__pycache__/food_flag.cpython-313.pyc delete mode 100644 backend/models/__pycache__/gp.cpython-313.pyc delete mode 100644 backend/models/__pycache__/ingredient.cpython-313.pyc delete mode 100644 backend/models/__pycache__/invoice.cpython-313.pyc delete mode 100644 backend/models/__pycache__/kds.cpython-313.pyc delete mode 100644 backend/models/__pycache__/line_item.cpython-313.pyc delete mode 100644 backend/models/__pycache__/llm.cpython-313.pyc delete mode 100644 backend/models/__pycache__/logbook.cpython-313.pyc delete mode 100644 backend/models/__pycache__/menu.cpython-313.pyc delete mode 100644 backend/models/__pycache__/newbook.cpython-313.pyc delete mode 100644 backend/models/__pycache__/product_definition.cpython-313.pyc delete mode 100644 backend/models/__pycache__/purchase_order.cpython-313.pyc delete mode 100644 backend/models/__pycache__/recipe.cpython-313.pyc delete mode 100644 backend/models/__pycache__/resos.cpython-313.pyc delete mode 100644 backend/models/__pycache__/settings.cpython-313.pyc delete mode 100644 backend/models/__pycache__/supplier.cpython-313.pyc delete mode 100644 backend/models/__pycache__/user.cpython-313.pyc delete mode 100644 backend/ocr/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/ocr/__pycache__/azure_extractor.cpython-313.pyc delete mode 100644 backend/ocr/__pycache__/extractor.cpython-313.pyc delete mode 100644 backend/ocr/__pycache__/parser.cpython-313.pyc delete mode 100644 backend/services/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/services/__pycache__/backup_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/brakes_scraper.cpython-313.pyc delete mode 100644 backend/services/__pycache__/dispute_archival_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/duplicate_detector.cpython-313.pyc delete mode 100644 backend/services/__pycache__/email_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/file_archival_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/forecast_api.cpython-313.pyc delete mode 100644 backend/services/__pycache__/imap_sync.cpython-313.pyc delete mode 100644 backend/services/__pycache__/kds_graphql.cpython-313.pyc delete mode 100644 backend/services/__pycache__/llm_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/newbook_api.cpython-313.pyc delete mode 100644 backend/services/__pycache__/newbook_sync.cpython-313.pyc delete mode 100644 backend/services/__pycache__/nextcloud_service.cpython-313.pyc delete mode 100644 backend/services/__pycache__/pdf_highlighter.cpython-313.pyc delete mode 100644 backend/services/__pycache__/pdf_rotation.cpython-313.pyc delete mode 100644 backend/services/__pycache__/po_matching.cpython-313.pyc delete mode 100644 backend/services/__pycache__/price_history.cpython-313.pyc delete mode 100644 backend/services/__pycache__/resos_api.cpython-313.pyc delete mode 100644 backend/services/__pycache__/resos_stats.cpython-313.pyc delete mode 100644 backend/services/__pycache__/resos_sync.cpython-313.pyc delete mode 100644 backend/services/__pycache__/sambapos_api.cpython-313.pyc delete mode 100644 backend/services/__pycache__/signalr_listener.cpython-313.pyc delete mode 100644 backend/services/__pycache__/stock_history.cpython-313.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c6fdac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.pyo +.env +*.egg-info/ +dist/ +node_modules/ +frontend/dist/ diff --git a/backend/api/__init__.py b/backend/api/__init__.py deleted file mode 100644 index 72f2cd4..0000000 --- a/backend/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import invoices, suppliers, reports, newbook, resos - -__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"] diff --git a/backend/api/__pycache__/__init__.cpython-313.pyc b/backend/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b110fabbcee1cc4da084e1d22543275762c6c147..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 245 zcmey&%ge<81f5@UGwXr$V-N=hn4pZ$8bHQWhG2$ZMsEf$#v(>9rXnUU<|1YXPS3kq^FQ;Ujku@|Kl9G}&)46fpyJ7O{W`RuI7kBG`e%E%x~M#GIV?_>~Nwfn0`L z{D}pbG4b)4d6^~g@p=W7w>WHa^HWN5QtgU(f$|_16bk@}56p~=jQ1I=t}<9X;&N(a JFX9FY0szfwJPQB- diff --git a/backend/api/__pycache__/backup.cpython-313.pyc b/backend/api/__pycache__/backup.cpython-313.pyc deleted file mode 100644 index 568cf826f1fd9da7eb4cb953f989ac6fe3b5fdd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16160 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 diff --git a/backend/api/__pycache__/budget.cpython-313.pyc b/backend/api/__pycache__/budget.cpython-313.pyc deleted file mode 100644 index f95405fbe1c6672372999568e74fdf939f9e30e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51245 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%0Dlf>-NXC!?8ftoTVJTU=LfchfCbcoA=Ij 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 CFL4C` diff --git a/backend/api/__pycache__/calendar_events.cpython-313.pyc b/backend/api/__pycache__/calendar_events.cpython-313.pyc deleted file mode 100644 index 3e2a2ff823843edae56cb05b0cde25581ac4aa48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10330 zcmcgyTWlLgmaS$#`4pul^`ais!Ju1X|ZiHp(&+E zC9&hoC}Ut^Z3eK7Nni&UAoH03^RvKw>~Cz(?qWX{A~;ww4SVCoKClM%gNlR=vR`{| zbvH>-Z70ECQy{Bvy>3m|&x$h=m1blr&nQ%3dBUuF#zQ@f7H7RPKI)tCQ$I^fvw@iw z+QMjgHaOEtTN$m)w#}$iWwd)XG!v#_Mtf%4XF6y{lq94;p9W?F00+7j|0Np#pbELAHX7c?~? zBsdYOYoQtyPN-UY!lQL0dJ_>W8~13P-Q+dlHLf=LzK8VPwXLUz_3P5Q6P&iQA4Q8a z_0tdiOf=5UnptfQkDQ-N>q&Gq^b;9ZwBDv32B3#2haURA<4pP!Ls~S^ZTA8_3^esH z3_Z;Lg*{lZ8ke-eChof*vJyk~__g6j%81u?!3^sBcQ^4LIolQ+d4R)@xeOtlD(Hs0 zkU6iX^=u&(;|%4;%Z1ERE|rb(hI``W7voDS1)Uo534K}5rSpdWg;S?qo_c#xXVsvA zZz6vox2Wm)JTwEIQeMyMiv>eS<lB{;$y62? zr|8t1daeK&L9DX4LMbGZD^O{%QP!4Xk|Ois@& zC6g5AWk7B!ms={Na0T+vh|d=&?ZBdNGMUHx#bl~bpqbOKC{Td{Qj$&h6Qez~oJlNN zvP`lu=SP<>&^9P!5l$u_h3I`!35H5Ss1j5!|G44}T%M`)4X(~!Q?C`ScmK06NU;)Y z;wp2?2VTLhk#|Xv5zC<()rNjC|3&fJWiMW!EV*o)gHVx3cD6J@o1BDg^`o{DFMm}vAkb~-w_lsl6- z`vAv$iY>!jOmbREqvZ@;I^UoO)M4Qa$z(?#19r$VFGKWeQt9sdpv7F@QOGcNjK{GX zK9eh$n}x@kY_qU74&Bk+SOp_2%pZUV_KD)X{QM4OYYm~Im4paWz^?^P?B5~~nS(+W z1~)cmxY;Bb?8t>B6MuY-xM;Z2rwxhfg%z4xwD%CSCO-IIhX3Y2!g>#pC@jT-y)A(#9Y)nqigIoWwuZs{`FKlHAtdJoTS6~_5ql2y)tlT${G8|`IdNW$ zBStp`+eC}2O$Cs&%W~4Zq>`V?dDj@pyHxVR0wFnh0U?fsc{z?C*AyVvVSbQ{+S?Q$ z7vZ!iKrTXSQ-E9t$;44Fnu0xk&2?0I*>&<2LcFkB^RApcOcXNrLEdGi&X6bhGhB?n z*!@x_Ux=EATlCBljiP!)X)1SCpZiRrxIdZ@%RClhP!J1JykSsW6+?XMoKAJa^LnPR zcuvnHEu=G)C7RZ0^7I9YO$=FoTVI5u){ygXw5Dj@;8WSGsq3sW6mk6kG2EH_5)A8n zst{A?K`eV5qeB>tLu80=tmyOtYHfZcTQDSZDh8+LaVer!paWRfn}x|*Cpn#joPF@m zhd`4bkX5o3B!2b7XG*?-vTvm58@a25%SyDUM1TKMdHAU!{FJ9Qd6!>Wo%$bzD6N%X z_=fko_jf1DJ>$im@xOH!dnQW3<5$FgkpsS}42f3*p;dbk8q@SD^ zoaz())FnXTPkkc97v-^caNoSkjIKq{!CJt5dl~+lPt=eTu&#!kwR&Ws)@q@KytZb? z@t|o63vN53UO&z*ymr3%oadZ!5m_7BIcV2BVd!gNdu=2R2-LtQxV>*o*)8qo4wT{n z*B*gH@fzmTdD~hJm?!{BiQ7<0TtK^x1;9Til>_B=iz%rBA`5j!X0iCh<(7 z=m{{f3~f#ZNAzi=p23J&8x9CWe*~pqHR1NG!w!88lM@&nhbWInx(PKV(x`alGNC95 z@WpHmikIj)to>gQA>vd6B-D;7e^&|o=G~IgTULgO%Ftb3@NTfT9Nbe3?)mhk^1jI; z{DPD9yc5OXflZP32R3=p?fKe6yz1JX51%Qkj}_I&N}he2emNw4P2_;II$aHtKmF77TgJm5E~*B*f#h;C1>zq0Aw^9 zUZC2RdP1#Qm?!ZXfYdSx~nEMmlXsi%aLvX9|W) z^)p~JoHI2ajT2MV1v-s%-NJCgkStwB0iv3lO71f+ZDtug31wOM=Wjv8?1=zr?W?H$ zWp!^+-CI#R%j!^39jX%n_3GX7SX1K3Kv<0OaE*a-&gZIAa6JYX=S!BSbM0fOezcuhF!(m4PbbT2i9(@@j7 zWM0hH>>#Lt9+{?KwRI?gwl#YR+B)24thNp%(6)BZgSHMsJl7-qW3G9>FeCrG#*FoZt>nJfNDM4MwS;nLFUTi8pWK;F0 zJzGsQVexrxZUlhwZTP1cI7%18rkgIRzYQ0dOzy0SkaG_{1SR_4mML_nM#0Mo7r2#N z`ZJf|MbG9+KDh`^Vna&ng;XYM+DB~qnRm0~Qch2T@sfilRiMD}COCry zvluZGst%;q?GY`WI+~h>6C&&mo5^QFhXtoUg(_=U?I$otW-J9CX)HyH!RYUg7lXU& zW>VdBsvAq2{5I3bua^?!v(j04RLT37-n&$k`z!4|H?r5WSH$0VS@o`>48L8Kp22d@Xt8G$8Fk%v z2KM=qCsGz7>q3M9d+_R!wIlU|qjr?+I6!)6Gq*!ktJb(ucU|uyAL5q8Ac>r*asFF?P1lZq_Wc zANYdzUBKPZcKcr7j@L8?8_!l=yo!A*xVxM#dJlJCF)J_(3)K|tK6k>S1}|X_aLl&) zIl#vt{Cx~ULp?h8EZnD7c`@+|Lx$YfV7e+P)P_w#;82Lg7f0KU#Mh%)Z=|Rn!6hO0%0)1-07thO^>ZCgPjUc{{SzZhM3KO&FZXp zzko}Sbv1&jW^lR~)nBc%3yYJFy5?tDz~yAYK3N_8y7p%Tq+UNBuv^xjU?DiSEw> z$?_vhmWM{TIEEgYoc?wpIv2IKZlkxRwy~pcWzygrnTrV~Jm7-V-#NnDZf^lz*JilkpZM)N;0*-2g|0mgxv*o_=V&8bF?+Ghi>FWNZCQn35_gV>5Xbfy3i^}rD+<%` zh8J%`g0BPs5h)^bKT;>tfo_`~o%EBv%^A|>8JC3ICTZ+sQ#cPt` zsTZ0qLcGKn%JPMDDhFQzP`sD0u2ZMs6AOy)W_aO)13jA`#SbSa!kE!+<-iLJ_ML^b zH<<^*P*N*}bEB`lRiFrH@Lqy_?ZICDn{U3+N;Y^<0Ykvo5$rt#a}658@)D%ck6`f2 zD+M|R6h%9j{tP2L7uk-Yc-^2;j0PYwl&8&aB7Q_EBw?qK{3#b9Ri5LxFUjyd68VxG zE|SCd$k>-;=pO0&4-&aYdhU?}_sFh)b+>&GS(UaFfg8FR+amDT3N+GguK(tXTLd1} zZsJnj?|!fQ%H-w#`_W-|75_z7bba7psq67CI{I$D`P*M^xcfM%($aBLSo=kl2l~E0 zd@a59YE=ZfDiO7#DkG&3cesjms~#eG);;}2F}mf2^d?VStyLG69OvxrUMz9#8xrXr z*xr}i4Sjh6RUV%A+uExlJfRQZj3v-Vjk6ol$ho8H!jfaiIPzZPidN!UofvlkV{c!T zhbLnUPm3{9z&Nty1`1iWRb7~O#2)MOB`&m~>=>(i1XwEGDi6>5zW%BRPfH$1p**&* z4zdhaU6^-@&A<1(-YajFxDKabwsU(JPk6Fn!jlbiO9l!#byi)N_qa8BdV8@%7z dwqx1kUYs$A5}u3|JS|p0A*-Pc7f@{1{U59tA;tgz diff --git a/backend/api/__pycache__/cost_distributions.cpython-313.pyc b/backend/api/__pycache__/cost_distributions.cpython-313.pyc deleted file mode 100644 index 9711e4c1b84e4b419b30421df086c0ce312510ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39855 zcmeIb33OY>l_vZi_Kg5Y5F`k0;7)MWLP^{tN~EZT6e)`kBH5%(LnI_oCPDdulq@<< z=(wjt#hnqAWkfmd7M=EVej7Wx9+`FxSNw>;&A1SUz+-Vp5wTGPY?2y zlMLLQ)Nwh%2I_@+7S0>=4m1c21C2uCK$Flk&@40$vi8;#CEaRO2k%W#CEgTYQ)xL#O`FV zwTP|Dh~33v>k;eCi0$z;jP3R{jv2?)ARo@xgwUR`UJA44W<0YeUrQ=aD{`>hZ5H1q zH+M!O^!YZA>3mzpdVQ3pqc_`hzO5{6ZyDzs^0kfS`nDl`d#b$FF9 z5M^Igz{|%kd)L^GjJog7Dt`#&H!GDd_uNBzb$#s*WR*UQ(v^Dk;K#FEn{30-=fEbdxC%n)I9x2A`U~ z;0e#q%mjp~VAykVR`8sdpF93Q)re=bZ z!RfgGqV#)%6H_yR=|s-RVgK;n@dJAf?Hl($;^h;D!)NLBz%;_<$>7Pr{Pf)9)Wn=u zoiL*2BeU~!L4n>si#CK4IR}m&eWd@Hi6DCm?^yPPFN7w1!EhL*cr^)KI5-`gm`i9* z&W9!v8Z=`(p+hx-b3w{2rA~#WX9JU{m$5ex4i3#CJz+mN6`YwlS6Tw7IGT@sF%*}_9!*FOS6g)T=oT0F9{_NT5DOQGUaB6Pi zbTH%tnx{gi!d`8{PMwt5M~Twx7%I%iT7qUJoY~3!p*i6~A}_NHsUqv+hJtgaXD3l1 zJ2hltUJ!zzx$${4Wg>F~B&QVaZUbV!%#CrFq&Y$5;{|mMH>MfW`c!pD8Pg%8rjSn~ z=uc_Ax`b_AJr9OHpwxtA8nqvv0xrgL}u)Brr|lE^h9k0lwWPsZgS&N!x_*kpi$@8@WSlLd1QUxh3-(hQuO(5CO-p@@8+_1&f)l1GiM*-bbTf>4h7($#Iv7sa zSg$4r5b&Nb&jkcFKxnvICIc73pN}FZM6BFZW&C8P$}>okc6HqO&2YX}DckyEwS)TB*KV7S+_=u5VgA zv|PW^wyM6`A?DRYHBIsIs>Op#&#b5~J{Q$g#VcwS2bK*h`IoKlYHFm<)0dUWL}`5a zR02j=k|gx0@nrx@*KX`A|5-xh2h{Xo2}wlXUZWyTQ%DrLkcnYe5mmb=l>ikRwgUiF zX#~BiDVG{XS(F>rhLro$zfUdb#`I%`B;9HlJ!%ExDK%I~9x=x0U_VViBf+QVgWT}`#ra>}``9r z-~n`;n)*RuS|1z1m|^4@j6!Vg8Z&}EHF7Wd^kbmaF%ziKxQ-glK9gWMWyWk|V3G_` zA;aPU)I#VZz=+a56&?@I&I=Pk%q%A%sA7V`By%n(oDE!H-75?uvoejjSc{aYOL&C7 zafE;mU>!Y~KsnJd35BOk>JcR{zHs$bgjiK3QlX<8cg<~)R$XaGnOi{`}a z>~sbVJwYi-G(?{i2oRFOgddrPB$j2NX|2E=)wm@d1|nHL9o3c-ZMatU6Ir;fYl$RW zL=qmN2$xi6Dpi>xTo{)Rpa>ERe=E)gZb$q{o&_WxN0cM+I0{KTE}e%-E@Kv?m}$;n zAqxu;m&{@Fh(^fCDAmeRsVp0XS~%$4FEKflmIsrCik)PQTDJPs< zCNM!5OBc455NI-Gb9*IXeu6POBNJu!YFFilIgggH4-SjjeNi8ncZ z;(}DRNsb823ENSALQk_R)^z_=qexE!W->UDC|1A|8%;=@rItQPfXUa47Z*Nc_yj4t zo&e*;Cn>~+)F}#4rwY>qsFgxD0b+VWhyWW$X`W3yPjS4_fM81`>d)|>1Ej_qjkN0e z#o^@xE56G^QB8fks!kFSFSkTBb@Vi}+`iIx8FFQvBx*8w)9jFBO;^0MI;yEoGWJ}y z#HjHu9tH=Wxcqoj&c>)_Q@qTxxPR&Ba{a|8q8bmI16m`S_eQn- z(hx6XLwwbr8shsH*H4adYRY&5c3G+8>7#(jXsPEGEK4+p9LW^MPpRvtQ(aUpH;{? zW%Jr4Ie=E-d(H)>rUNIYrm^1qU=A+{Y*^Avp3so+k}$~2QDKmh*@R=k^b(p4VIes? z@l>KPFpag*BxF!|y-17LxeJM$fK)nGtayCb5c+t+&I6_-CR`+3B;sT@jYiU^srttV zq(;%O*ZPoQlpaBB$OP#1^vJlcG!v$L1PGv`Bvb$CaMSDe5QxUi1GvPX_DUIh9L^TD;5#gZui)OAft4%iykxpO4KN-~qA12?N z%#?4aX6qpajji6s?-L8jBWAGXi1xeLVCJEBy z@H?o2u$4fN0OMT5v=XLiSzi)n3!I0`5b55d21*pbcpd8>B@qG!8crZUwFqwmK*zD= zaDIuz$tvP`?v*y;VxG98SmI!wcylY(x0;C4E7}|0)wD_^M&&4@`E8^;5Y1_!0n_|? zMF?9CrQ>9znlS8>Y{RFA2tiM>n!}P&hQvfjERlqT9OSgNsffH0Cy5PKgqYYcMj=U9 zpb!%lSR4}(*f*G9;L8(inC=}3=P{-X&D6M!%%18Kq=P4rrYIXRN0H7hKE2?qD&DIwNvgW?^ zvt)58CNv+7d505I8A8qs6$9fCG_^tzQ?)X`f@u%BmYR5JWmHocFD!{_O5%1lS4z-g z``tl_6sbDOq(~g<0i>9cUon<5SLSTIZ%U5uF-wYo*tlmBs5G~cR4UDC6q0mtM$AGc zuQ^e$kyygS0e&AwGn$i1h3wVAH{&s+Pru_cWV{2ZI%M$cGv0wND*2Al zZyd47Wu@=LS?Kbsh~g&EXG1l3(vIY^=ykwvl)vTShCmr+4);$;_fdzOb<`<`{8~A` zd=F{;+WTVIl*Wr+TfLqF%znxAdKbbAox`*1@o*}M9W+dso$<__PJe}}5N2&)1^vbj znFwaJ$z**yJzld=j=~c<2_Fe9QydCJsR>iMeF?1)2%QQFG^!JNDhmZe8+?|`fmkum z3TP6k0O!0WY2mEER1#X&ZwU>(FI-}uKrymTiJuIc5a&r&W{W z{=)^p0l~=y1j?@fA^M)gq;kdnc>A8rZ zVgNY#{l@Of)YPv4jwrjCdrPcO+st5;x~BKmFp) zWMp47Z*Ro1_qIK6NqZ~5XvO<(e)ZDccv01>Q#U5X+AguEYiZzCS=H*EKRCT?iszTT zT6DuCR&p0DR-4{&w8Zm^t{l32Xf^j=9KNgP%IofEIcLp1JC|Gd zrSQtOFD^vwl}p<9ZTZPB2cmf=B9;?zTmF*em!`rMb)>xYhVJ?^k<#|4sUxE4V2mD8 z5NR(G+6mI!uhY>He`h?MWC+p&`*_Bq!5dnk_dU)xgAPWBP_wR4dW&7)*va(K6vy|e zJ5(W+U)2g8-i&XA)W<7)nr3t^d(s?dVL!NL9m?>lt2wW3!Iu*0f}VhfRoNqH|2)BS z!4T}?0?CM}*rG}!0~8BTx>PlP=J8afG`Op8Dh({D7JS~hLQ=rPFg z!oNWD2mc)xtlP=uCk)Mm4M7Ls)e1yd!oLDY7|%k1!pcwRrTV}Gmtr_rh(de})2obZ zC6dj{Sg9tpmBKfW<*)D`rl}r^aJ^-je_|D!ghqANB@>O0m!|?bW+XKYre7Jw^etT(rILLt>q_Sv!E~H`bspe2`D-JosVki; zqihH%*_WTE_cOjgdhM)h)GbGhl*pms_ zM1MLTIUY#EeAt^W5a|enpM+szHZTqQSrBv5{ER@oAsNgxXW)M$6w@=2KGQ~FmOUL5 zf^6hS3pwhkgiiXHz*Ym~5Ln*A z@S@3P4keh+=Em=%(h|Od0)CGFFyvS612BqvCeCR4y}3)zeeSuJk6t_e((#+MeX&}< zSnH3Lej;-G(WvdQsA2RM#+-}!H?3tiJv(EbA<;8*^GG0eWKzVRXEJIG{=#Uz=)7g| z#w^=K%l6lsM9aQgmXesIO0-nntm}x??H22H$LjWp__OT0t5us!YkE#=`g_c(P#QU} zm@k`emNdmmTE&vq$kqX|WFTrET+(6{d8PAm=Snc@sE*~pVtdP+_oc1N=dL_=`MEEC zI#$?ty&~#pikh1vn&vxd&RX+Pm`3Jj`gZQ!%zv+k-`lGBKDPU>-BJ_hNNQ}$rGpD_jPIoLDDXW$KcWzu$y?;_W4!b0#=9C^ zCMD3PzbcPyaFEopi7kOreAB1$WiFJ|BMLloG{qxFQk*gUjn4DQB~!XgjZZ6cXeZV|vJ<&6V`N_;4VIlc;6h;r#=`UaY=N2L^2h6x+92a~Lk+R~93h2Q z)|D|EKzQ_Uqh2{fdI1|UjyB4%O6kW}NLZA=95VTJBeWXMx<*^&2-HMr$vM(@ z@wEG$jWfe?IAnPX^uT!yFF`ODzKm-8rr|J& z^nHhi_Z=J=>fbBB2Js$cV40jM8_P;FjHZ)tb0Pn?-&FRo2PzLL0OY2EvtHmg?Sm^8 z#5GQG8JVZhIRb+O_7VW|;e=-hTmZl%PMY5=ivUyTdn<&$q$DO>5R*&jXPHw@SRl=b zr1bv_3eg7>rl%x7Q_?@@NfN?1bOGbD0$H?zY-KG88HJSHm_h+mkFyi=GgxYj&s{i6 zhGf|)4VR!I%Bvx7OeP^njUiDap`HmmBfNqTGY4g~H>;`7QI;Tqlk}PyYpO}A6N-cm zfHnSml=`QXs!#SJ)0`&^a!nFilElIZ)$|k=Ly}Q0VM)6t!6au)kw_TBUs@DN3wmj3 zs!NzfE;Nk$()t2g87DvodCR)l?n_`t6BK|BLOrE)`*E@B;=JYn*;(jO# zYy~lEy=bkES{oMkzi-aFWreLJQqdN*Zi^VU#r4+De(L#8y<8eA+9Vc{A|aVR6txaV z48ur|=}Sa?$!bni-<(YAk6QOd4Et{9yB3F726s&Fju#bQb6<7evgCcKJxU^1rD&;q zwf$>duXNq0i*4x_@mJL!h0vu|xlO<3RQaYQ!bb7G_y)=JuKJIc~vtG5va~)R-FBisv z`O(XxR1_pRlJcOXpr({xEsqs#zTO|NZj8ID?l?>ZroZP*M)MsbXREm9;VhMLhnqw~ zSk_~O&0=A5+*uNX*$N*jsEQSIuAYz8b&7SJafj=US!dC$aXPK;LqCsx`LG&|zmvx~ zcJm*FJ3tDb>D3Gr@ZZ((16Iv<^?hbMyqU)j3X0H`yk5MD8n{LC1%3Iyw4QUji%&3WxOD^CG#yQ zj94WJkW-WZIVD+7c7K5(CMEMJ$$9B*f-uV%M$?iU$ybso$LQCCCP1$t)A83jzmzn= zK+av)?-sww%K38qIzX%62x!}yLAgXTpk76J{uAb~YLi5HSohBUg`x~;9#;)PY@u>O zmJp0BN;0KP5F18^G-s1)i}EXL-u+0(XwBnQK(@2uXffmt1NfyVQKYd1PN!W;~))=ekmDBL_C- z@)dM;CRewaqq;Byev*G5Psuqqth?-}*CF}c^gTn~hv4Pm6#FOFpX;~N+A2gi zQ>eE;k0>*Makvh{F6G?7MUg)b(0yNA6=zZ}at)_zEQdZ;%^k`$@H@z)FmgmLD}B#s ze~C;*qa$*n4pYXQ!=LXfYe_j>dqD3)&q^cB zpK~92D3^OV$d4YAzpKn~G3piSGJ0HoYveIGl)fv?Js6z|ngYa*rQef7z)z0yT!}g= z)W+xOFov9%mB!@~(s#d8?gel#)kq+nB8Oy&BAeoo-wMvzh<+**>pZRl%|c1eLwniDwhiR&`P?p^DzyEcKCQ$CCOAL zNu!||?n^$K*|TbRm1#H{pyAk$|4sNWW15cIg>tAB$iX1s2?fu?#t5TgN(BM+M$549 zCO#`bRl_j_hVVlQy+zPY2FXQDp9hsUu50A$$cdd_cohxJs{Dj9UC)9e)!3BUz_XxZ%B8vlUnhK>ko0c=yc)^AQbQ3m za|$#CF0>pa({{S2P%iUdNVR?a_n{Q}DZR_I9j7To>OINvj|Lud@e^o}B~p(1CoV8U z(b0Xc=_HF0E1{2aGdDqU~j1B(=PCO^0u0~Zp-@_Vc7q;u1YRLfOn{bQ!oMXzV?Ut|Ov3x}h7639J3!7Y(?T3c!e0|e5J*uX90g_Hbi6@T!gdjQ%a|$#o8F}@RFX464!b42 z1H!=A%v?gt;=&~;TGE**!=xq+T2ZuOd;=8-{|)bZd1%t6(XULCX8&z9Y1P+SueOR7 z@Be&l8oV1~=5qY4SHM9-X0Uwr+2@~qr)gKz-Sc+SuGOcn?Ra^I*tAP@_eAwQQueB? zH}?Md;GYcM*cqwV6Sek6483@>z;)%Z%a4&-@zYU5dCX8F8fsphjn(cJYj?kKD7JT0 z#9!@b)G!96Tmh7ZD!Xp+0A94aVzvg+))2KdEgpD(`>v&DVzw=B+qS&XyLjLpbaoAK zgXNO>qIvmf%vl59+10sNUAtJ<{(AMdTfW)yR`p-C{-_m2=Ia&@!LFN=duiZv11}ze zI=B{uI=k4(l4H&auK_$W{#HX2a%~x9e?hS2VvHaCOTS|7HJ5?-w2wyksfV>xAFPT5L0 zR^BR>w?=a|EveqO5r>sRa#Y;W0(k)`?mK%?Wr9H8dgJQ`+jF07gFCKn%W=+kN z4)AN7s?-!Q6al|RPo%0%G_*xvKQ|P{3}vFBEM8J}t^aC&yrNpFPTXA`uh??OsP#bc ztA!GEGiR?}+Xa*RYDH9Em8AG1QR_&=FhcZI9e0$+91WtQA*p4(WvhtU>P1_9%+?~> zTH-dxrDrcb8@J|NI&<+%+_NL@-W<;_j^)>j`Sp1BO3&pU=xmAp)(SYS{Xt6NYbd&7#w+ku%lF1z)z@rSZJAY#IqF0QJdx`nwz_x`e9v8Xa`dbg_RLzK ziO#x6-LCJe-#8TAeI%McvN&|hQLy~U)yLm;Y`v&oQZ4n}vezW56ECca6}E_lEznZO z9oW7?9_lDJ*R-U?7)aJ_{-5XEad7!v8bssrm2i* zDu0>VeEr0Yrf6v^nB>oKr8*xhqYYsZvI09n7~ILHgV3Kh=7Xv!vUVtI`8ZS zEck0p<=>clJ_>&oocA-m*5exfyLSFqz2>{Qy`^~gev9*1qwY=ho(4SppqW3mN%Mo2 zUI!k2q^%{`Sa58s=ErOr&Fibi!@Jx0W4ko(?r6{P0UV(#H#q?Dow0fOW{Tx!L1B-@f6-gFwQZkBDkP$B@fK$3(N;oSVpBMGyw9I zkH8D~zq^Vx-V@vtd`ed?=g7D-TRTY=S=T7Z6zm!$S%F>h`eDQ<$E5FRN9T0@5lcE< z4#{OG=2uvg%K7D$gOa;*#uw$!WZeB4=7EuFg_4$ZlyJr_B|nG}mzzX#{xJMpG`@SR*A`T3uK zhIR1!_=nQ4&aZ_nMjuvfPTOM6E7_Q)Qnr|o0a||OgQP3Hr6FYW>!4!+ZbEu^c_@rV zzkzHxY)gfcoM$xUmm=dfWFDoZD!bB-)**{x zY>wXu9lQF#e?>j5@PV>Hr>VdXWzq^@4W*nz$qtfw3vL7t3!JVh$zW z9$3WqP%ipz_r@=!O2mw!oMWVK<6H3LOU@%GTLarb9(4Lzzb!*E54{^adtsH@st!4P z`q6&56ntf$98TZm@_YsxHuFH!ZdIDgF+!awYiT1e?1c99K)Mt;lxh`fF+`Ymjtt0Y z>HE4|UdjbKE5mYBXZ9B| zY`G5qNk+;45?uDMT<6i0-GYt@@)tN#hmFeklkd>|`sBBu`AzngFNaxc0;p|~R9oDw zBzT32p$YcOZ@`{d?01vhXXF!dT>9>_H3P4{9AbU4loTq-sGA*iE81AMP^m9>SRo-{So$f2F^Q zS{SPKSNcqBB-+uo>SP!c>o1o_(|QSjdJ6?a(W9I!TO zhVTL6b;p0kFj|wg799m;`%SQB&ZO%ghvYs5FRuMWU09v;Ai($LL8+nIP@TUvqn>s0 z7vV<$37NhZR)^|BnRzU7o{aZu6ykE^IA2MJo>_2o!%r}~&3{kYZS>4;qaOa-KEG?T$Zt$wn;v7R@jwjBp*v@g}+Xh9&YKLp2eP(@UE6rW+$r0wq%f=I8rnC zt>GI_e{K4YroT1((OuFBe8%PG*?u|UkMSg|0IX{r!7 z*-n@{SUY*vo2ms>O<)*1Jq$RT;Ut}1>&3y0C`b4jUe0W>prcqlKoPZpOd_lerTyQs z+}M2W%+)j3^J8T#k+PO=Sw0H0wv?8`Gq7o0{lQbv8R#A2GGB&7?&FW}N;^dSsd{6( zqH5TyPpF5W=~o}cF?;G`wE6Yu>G_0upD>kB`vP+S^C2`tI~AG?K7#=NOqj5dJS@-9 zJsp@iF&WsEX!sbNmCWm=c9JP+k$by$Ce|Akd&7~r`J27x*zP}kMEz9o!a~kEdl<}V zbWj_a)rcg*3-)!%pwIJ2#J)l$!EsiQBU24U(gk7tn?K+^r+N_c_5$Nd*(?BLV|rbJo8N^aa{ zzx2tApIkWu{pejam*ZU8bLqf8Kk(A@M`29%+za_F{8t<;{JIvW==mIS>{4Y=bPKik zs9L$r>!b)KPi&AP9)yQY8}J~w(P&sb8R;_wETcja7O0NELN*$<5*oH6O-6%9Mnloc zk!xdD$KJ7RyMA1>ZDY9jYuh$#$jZbCDcfEs?%=fry#(ZS>Fq8I3!ze9k06eRcY z-=)xJ3DDFbnK73rL}oL|f_jlcpCdq%pI4vOGYXd}_6r2knZ8Jwc1Wh#zoeub0?e-X z6ABT@O9zMDq|jdwpn@~3j=zt`gke^g#AaPsY!A?*Uh=*t+uHOrIoc0Ba;X1k|6V%0 zcw8u@0$BU{C`79^$)0JT5J^}k}ZxVQ(KpMMe=<)Le-Uaa1XQ1&Q ze}|sUvd2D-E5Je0k{y$5sN><&*!s^%1SgHNQ}SSd7l>dJ)}#%VjGA=99WzHW!ns3b zYT1d>I9C`OxS4@Ep`V;RkAuH)91Gd7>2Mxl2|0!TO4$*I&w;-o8!9(`;$$K>S@QVY zX`B~E+%1_laZ);^g@1~j2^OwL0Z%@uY)s9LO&VA4$CZUPH0}Szf4EG?aUU^;W9Rb9 zV!8EVZvEl_j3Vw5=J1cx2yEq1gNJQfxMgwOEb_v0UM$)iwQR|HWcw3-ZQx8c+N6m+ z0(GLL?s}(a+4itan)FiMMc2)|%2-|v9gJ+Oz3Vi)VX`zc^NNeh_ejQJ%Rvcd}SsX|OerYdV(%g2HEak+_woBcYy2(C=87XF~!(Y6j>TB*-+_&=G zFSkeYt7G{MFkybZ{g1m|>w>+w>6=Zl&JhuRjU&-~AFRfec`!tm!jI9}cKwV_vrmWSSVcyMy^*DSACVxD%<)4rT@dt1k!kNx@B^5m82%hR!f z&2JZMeq(OQ9Pd9E>-XbtWpAvcNu<5K+r*M>H_qHF*%KLk97fEzqdJmTv+BEE8uRwO z?v2#%joABPTGMfbMK3#7=3?btVtH4zsQYza#I+}q-wX3=jrVI~Umc4U1-?4A%Dll2eWAG~#k)!_D(I?+I`sB5WSH_mCk)i-0r0ZhWN`2H^`A46MyX&v#itfgkd%Nh~ z9&`7=hW~~s>K%C%Y&+^=|_Et&xD!+R4djBh9vFaUS^^Vs! zi`Ct4@M6`TXi4v~@s_((EbafMC0g1ab@wmpC{cVbmz`SjF zys~8Fc+_5dz2=XbUTYHV+Yyf2-7CRs(^sc)g4`EOFxM7T-g9w|f_o${#mg$MO}{jK zSB>ypPVF!)>F*i1QqQkAwI?IdLy3}w+rT;8k&(#M_ePYADX!U+NI%RLHx>Ky~jFxrXDlAz!_Qg*nZ*fQAm9EQOcS>ze zSkP^-pbx6J+_E*CQI%K1`0+2z6>&#tJhuqv^2S>_V=cSImfbg7dT%=W-froAWpXue zJ$E?(a(?aW1VTOIsZyXLLVT7-Y( zagKFqezeVl@Q;i7w7C7OjDMWd{j9t%4-b*e&d1d{(PSf7sv}snw*U$6?CkHt?dR=9 zj~nc_a%&L2)y@;_(gD7wHUlo?wLY8an1pHnIVSY1+ZRO=4@p9I{|n}|q{RC_WtY67 zU6{mDb}3miWDSDietf(9X8JDYm$h(8d#p2dQl)Hw=@K^V$1g~y$sw!}6k;N@3tBx@s?f3tK_z3e~X>U(Fq?|9b0*87Irf|iYJVt zbMQkndo*B{52lgil2l#NuYC*K)2CVt{oKr z1g)Fi#4Ajgvp!IM>kx}NqL$8>Wxr_IfAc^vc3@UKFdMa;eV9Z?63}XoNTQ!C-*v|t zdqwNh%W&9t*0#yHC7NEg#I}c(3NT&r2U2KEaeOLfWQBZx4lBX`aMm)(t#!YE! zNNN*i`aA14Ve%tHa#`1jT&U?4Q~a7~sE4Er%&txjr|&Ye%Vt;}GCh0K`Q(s4wdrQ# z8|i~4j<-FK5&k6@;THTCqHe(}{me`9tO0O9II#zx!Riwnavgt@w+CzoJ7L=c z7OMM@cb3jLm}fr~<54=f$XoC+n4z$P3JsyLlIBB+4W6b{>RHJL`}ZkCSQP$00{@8s zyni7)qefG_u2@bIRA@9)yQX>?Jzg74p{d9yN)!fkwi zkLJy7S_*gZ1a~snLvWq^rbo$4elw++_dkyOra{T<8ledy>)Id*!CIGnfdweUer!o7 zC%ZgYsFP6}Bq5uecfBM8mZ2aAj;2`9h=Po#$hwaw_j9Jdlg$Pdy!=v(8omGEz3%B9 z#G@&nCpv^5yN7{6=4I)TTbRDXw+k&{dsj2-Wk9PHa>Lqg%-p-`Gx^MexdXW&t%v*V zG2&NGr>Fw`oC;^uPMyK(wGU$TC@a+u*nQTRE<+B<{l1aK`z(HVFSMwb1(6>v0|U1( z25w!L0~f~6D0%`LrP&q_?Q#lC&P;`1B!sRJM{Z;L5bPnD3#PD%9StX)smj!zp8BLN z)GKY+nhG*PUA3Ga@1nG!RD#+QEL*IC#1^L zPemrbncO2*Ple3PVgnf4SC#y*Cva-E#j_{;6irjCVbaNXWK1LFDH+oi6l8PhGpGgq zuHp=S!4W?x$%O5V)6;$B@Y7U(nt-5Z-!t6Tf9O#EUg_XG15~rDGSaVu{2gT^wj$}z zcVZ5~`E9JBr~!2M3ElkM1Wc`&vCxpyMCMKIz*;|ZVPg;U`I#Gg1S&NQ^{h0ZC0770 z=_?8|T0gR1r5SDCeP^_op;k21rgWJPlH`r1yJ{^-@>-^WwJ;6r{U!PJ(^98c))_0? zEtc($>USrnrM*#Wf5gzgX5uU^?32LXt>W@)oiBBMrTgpN*Ym`NZn3yKV!+|=g)wuL zXs(LXZjYL`%bOoI-)M?$84$M&L^cmb2E!5iTzZ?suS}e_@|L?v*1L6!?(W4yH}R7Z zo~EzaUa?WQH14UVaMZJzJ!VgYg>A1N7R`qunnNFUp~-jJG3R|0t^xKxv!`sIn*WZ6 z@86~QPQ_ja9x$uz<1wq55dHzr11^+rR8h3jj;`roGy5&nb?X!(ie*#XUUY9#Rq=n< zDL{(SG`3-aR!~ZS*($_-tO@#mvvo@Pkd~2JAS~-jPrq<~S1CDQr>1gDPJV5AU6Co5&AWfhD^zm84Pn4m{9*H8vJbFA1DL>0-6IeDbaeux6(gbZVJ_9fRZ08Yl7 zBo&XpTbB!KDU_vDW5srK{@bKkzn|WNb0vfta@}B#(_7{G&~ME74GQjpIF}x7U2w2t z=j3N%!Yr@M=+u8DC*Knmd{HswFz+|wfIu9nr&x08m&Ag+bm&R|;?lZa$YBSru%lMC zW}fMf797C(8Aqh3N62!Sb@|P*)?=eUGK7hc@G{yVzl5Gmi8pc_TBJDk?n6T+pUr3Y z<>E*I3u@GrE=3OcQ*Kvq0eguFjXB3}PN&xUID|sNQ7bTzidAwjR?-4s)lM=ByGHlO zAzz-11*E6SME&~Y9O?W01vumfMP*+PT-9S7NY{f`^*Gef&m6{7A?8YkquaP)Qh8S4 zKaUBCj)j9hHrqf*%#{2*Z1VA(xBx-x9If|LKlO&+9biB8Cb^VNoSqE_@e>3Pur^A5 zX)S;>qbX_ugl>o((oq0CeUjvt%)L>>gP2KPYiD6IZ}HG?wZgE@eBWqSbLT&yEOJRz&`!EQq+e-)`($4P0~mfeV3;1Oqi5Ik<(*_+5jf zCYey1#6jvO4Kff7#mf1BFd1H`_9eTH{VMH!cKt6{*XLUBq)r{6Zv;-zudYiz3ivLa zC$R6R@b9U;KL+qt{}bwH)btf567ME7Fu9n9jxb7Ki~w^8Vc4g+L2_I9j}$sXpqyUQ zQYcH)ll0wD(DX>(!?s1d2#~PLNMNVjgPs!BR18B$B8Q&w8*9?>_mVqCPV(13lSkv{ z;M5Fk)ldEyVwhM3QzoT=q*cQ2zO#}TsR+aP{C9{r-U9ePQIqsbi(TH~4V$kly~D8k zYR8RAv5L&POm4Sb>bckxDQ(6%*|OArBrrN7;0U*grjaL z04KMYt>PV91-!b9rg%f+A2wuI{L_dvW---no(+)w`2xkE2nmKVtCTuBxL0E5)kL<-J!1zcBcQ zl}@u%#76ZsEDlEd<{Raa-A_gWvymsyMk=0;T7`%~xU1vx@H-Z9tNqf{ z#igA3`Sq<@^$JKhp@{xxrce@O$}|j zc2?Muz&ft`8??ayo%Ds<1 z+yxZgspO31cbYQve9waRq31i~AwAzAJL&m4@$lw$=OKsY2R0kQN*BRhI=~-ldeun& zk)A)~(*4NLTZo6Bl=Fw&nxA-F6mI7am1us_k%#b4&HSM<%}*^_3OjTJ-8{h(2FnNv zq|IITD<7oldIA4;Kc`QvnI7`19v{=TL;Q+eiprn+tNt+V>5^rKYzc~fJ4ETALRo?c zsjvYm^T!f?hsjbY`9vz6U`pBzBgezqoRYoedMVnoQn?h{tWq*kf2=KPw3>R5x--M- z!w&q`C~XN?rxbM%R#F%s30kEjh2ouuVJ5Xgd?030xfC(O-*3yTT&8^AKn=8&`9YY` zvO~^!r6cCqp@XnQVPj{?Anw;=D5Ftb|e$tdzPg2jNdK2iuN^Lmy>KPdfuRjWKs?PX9g$O zv2%Z!T#1^J8<+G8J9_E7E4HFLIgkBI@>y4&*%N2*BXMk#l9K6SnAwEkS5Jr(stG_9 z!wG~z>Bz1@3K8mXuszGfRu$Ao8B2?QM(H*wozypyU;9Z4og#3WKpO#SXNG_OQ}j4Z zV1~eU0wDlzL&jQ(rh^QNHT%HNC@w;P92_z{>YeoXw*;uLg#L6O9GHXuH?w_{T}^7` zD-kp2_ zUF9)Xqv&c}${{H{b!z|Ii$Ka8>JK$;b+cl z#W7psJGRCp^{uj+)rsp}lI8yOzHbkGduYjb>G97!{?;5jh6+E;PMG<6!8Z(1Z%54A zBYJzjZ}_44JLb2-vBTpc{&tT?y-!BV0x?%$>0mt99m{PNbDN{NtxLVPsh6I*aWr055i8p&mTip}y03id z@~2YI@DiotoD6VA&4Fj=+9tL;pyut|n!CA19CDA{0<}wf?%6ma+n_XeaV}oyy7I}( zpQPUcY`@$dFD;9ewu+^#$>@7#ozZm1inrEsIjv<4KL@3|SHM}yVeg@JW9pox_cymM zpU0W|Zx=SdKKXug+ww7-yZ^S+`+7A_-e;Js6m6C1fYptk5D=~K7jKGr_KKdpad&OZ z-7dP@ah_Hzze&t*dcVGPMSsomk_GMU+!yOSg1_Z%=>)2nqfK?t19_@Nc&`G1&Cp?t0gj4CY(rLlc#Gycwmltq z_@03uhL5bVmr~wz^26<#Hw${}@$kc4{BW1#X%};9jta$o&+jxTQ z40bWNON$t3!{m{KetaB1t1&*F&`-~vIt4u*GY(3&NDYls{bX=rYKHc#;E5e;n9$+( zHz+5TOW67{F*{Aa*hvQg3I_JZNod~!XQu=*MhPYYW{NKeKRq3oo;V$xxzG}PX09a= zz7U$2niXi!Crs%?fkZEXRx1M8>;*feThr++*b~I|JS7ZgFHBePflYdH41@XVAO zpkF=}NTrrAv){oAx6C6#ATFA)CS#H&V}bGELQ1hGQ_{|r#90%zWVHM%AB;DrW1+DD zGQ!fCldfdmjh>XG&u7k?l8TB%Y3Y|m4T1T&(=BJt&j}v-nh}nehtL(ME{T%~9ra64 zz^|pv($A|2mGt6f0=o$8A;363VCm( z`a4Q0LCkwx&3jz+dtBqMRXSevD~^Db<7;ml?J=YKZKHdoJ!-68)cuVy@5SOp-93|v zFJGRz$KiI*Wa4XA9=*rmcF)c89tz`j&!FOKR*EQ3%{{A`-@1I^9*5h#5+mf2V;S39r|X-!X21{|2UqQ3atnvtGN9J1$Mwyv4!$-?PO zOV!Umb}xsX4)I3bv2qH`jdxt1V7D8SukZMi8LF^j4TnhZ$M`#H8{ZN)f>dD5nu^6j&n)#`B&Q|PGeX_xQ^N8iyj)`zx@{_1)3Q*q&Qe&aGz-&5 gjsh!E!Wq>89^FLr$pY1FQsB1M^(!sKFi!G60M#D|egFUf diff --git a/backend/api/__pycache__/cover_overrides.cpython-313.pyc b/backend/api/__pycache__/cover_overrides.cpython-313.pyc deleted file mode 100644 index 18c8d2a0d9046e5e82734fea5712c8877d72b059..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30317 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 p%=g-Rx#4!H_9rW=^ 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 MtG%m5D;bRcUqo6Vi~s-t diff --git a/backend/api/__pycache__/event_orders.cpython-313.pyc b/backend/api/__pycache__/event_orders.cpython-313.pyc deleted file mode 100644 index 7b3788c3b2ee2dcb01a1311507fd293ad121f644..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35952 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

BvrmuDjsvD>FNNy_ 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 IwPeozA8;bIj{pDw diff --git a/backend/api/__pycache__/field_mappings.cpython-313.pyc b/backend/api/__pycache__/field_mappings.cpython-313.pyc deleted file mode 100644 index 6de6615c8a4959e29333f2f96dbb62f618056e19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8821 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�_+%ozk}h2b*=L>e4WZG-@y->1h>$?L4sHT=Lh(p zmAa~|`S(H!EO=<1j@q6N8uT)1VkiN@8a82w0MQa#>2F}^-bJpq#zuOxy{ARG1Py-= zfAw=9Y!$oyuU4@h=G=X=Wa}*py#=ASCGdIT@E5uc73`x0VRSo)YYM`ozOM!UGZnvw zz?wS6;hH*m4A#^*@Q(uA$=8KP!7k)q=1%@dcyy!<`PVt5KSCOPL6hJs7Towzj5jpv zl@tWRD;bKB0!5SQM($MK&!)0)xv+Heka8n`D23l7;ae9jLe17FWGD~c!EUBgIrz3j z5k55;arH8MtE3oTXm$qc>LL6{Nb#_y`D&8Ah>B-7vl~WDPOavz9eVeJJjI9s?grQ= zR6S-sh>c!Gf=fo@;r5Z;H_~G$iz2}b0Zmw0hRa?Ig*9;{1=l7N0i0%$;3R0h#*~l4 z7~xCX3HnngMU+tsK&w2*ao>>OAIVsejD15!zaf3!kpBN7!EZ?Kw-$-B{5L`3BHZx5 zw|PDZu1mX;jSJl$-zD(a4M<$){hnQ@)4A&rxk2RNvD+?iBUlF>RTr_y9|t}RY|Io! zj;#j@-0^LTm6LXOVtKua9K_4D8mAr#g`x3ccmk^(dlDJ?x+h$Syj1FW`RlI!``7>W z7u(hdCsjIn@WV}&2mXntvns+9wuCnSE|#L+<5dgF&T^C3n z(O(te3F^FERV#8bjuY#k;?rE!g2FRQbKi%38^0)VA+s@hxx-8;JlPoGSsNp8s4Bc| J0gm0#|1U*px7Ppw diff --git a/backend/api/__pycache__/food_flags.cpython-313.pyc b/backend/api/__pycache__/food_flags.cpython-313.pyc deleted file mode 100644 index 9568b0be52f5e65fc309094a55764c195c72c5be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113833 zcmeFa3v?Stb|BgfG{DA(AV7j7_y_+4|Kdj?DeA+&M2VD55VCC2rXdoPC{v)^AZ>}Z z$C{ZWLuH(pN<5L2_^qUqc`GWJjpbzCTJzawN%q*D$$ZdmS`A~gwv#xU=Y4rOqNF!7 zp3KR6_g163L8z&ZJ(IK9O_Qjus;=*?s(WwUx>a>EIoV9XGuD1$>Stp#^>^ryA6v|F zv(rvdU!%MfM|o+lZU@cLrdfQNI2sTd%uHo5PQ;&)c!Ost>4Kx`_s8};>YgD z=+ERbiQTlr)t|*>5xaRuc7G0+L+q9vx&3)u9h+Lo+S8xt39L5!D>~$hEqtBFdYZ zO?lHsvzqm>Fm1%w3BKuZz8i^e2KZ*i`L+{Z7x-qy`EDY<+2ETK=ewEs=7MitobMLm zn-9JPalTuLuN!;|<9s`aZxQ$w$N6>=-xBaGjq~jyzGdKB9_QOld@I1$6X)AQd@I4X zD$ci;_*R2&O`LBZ@vQ~lx;Wo$b(FXML7LkR?{)C*A8j2vp|*i+^ESvWF=~N!X8Wq;dO7wzr@SE{<-s+|e6K0DjJ7G| zHhNnVQr`uspH<5%+q`Y}SniFZ4|-chH!3->-u8qXh9C!zoUZ!5uN-8!8@-znQunR_ zoz3@H{+7{prR+xUmW0%Y*MQE}drZB9KxdPZ1MBTf$icS;bnYhy6*^rBsqco=wR&au zJ(j-*%CDy0n~?fLkov1?i^?|d{iNP9x>d=6_4Xy?@UZtkc(*|rV9a^9!@GaEsXNOeWWh+l5Qmz zv-iP-Tpn8^m!Z#Me@g?N)_!@QDeIL2BjP@uwu-=Cfa_~bA zTD^YHedM6F_`?aQ?_DGHM?O>PBMGUG6FN2ubnMYMIyOOk9|Pa9INt!2L~H!rPf1!v z196aiJfSp`Bws(|yEiW1eQUJf_H8@E!8jbndV>i$9Q4kN2EB(y zA0ha2hG0jd_el)J9a;l=oRWJA@S-8_taq+eH!9hminBjW?04dt4Bo?#JC@)G{A5U* zz4HmBd~%JFJfoE4PPxSMX3r+1%#nWc9Q2zn#Pyrd8ZG<$J?3|GG^EtV=>1|sp0gy+ zFF~GPj>~gyjXM6;J?1GxkMaI?LVi!JQJZ^Bxn*=Nt~S4ukowb*x>g_k$~~5QA+CQK zy~h$#KfFfSfA=0!SE2INgp`l0k@5>l$~NfTzo)qF)Wh`N-ycnmgVBo#dCjkp*P@d8 z{p58VAQqn=>-~d-{GNgQw5Z%4D*54P{XOqX%4q$X_mA#6S{d&N7^NpipH*tgdQT~A%4h6_J_aScPkbj?lc4{VAZT3%32e^HKp!kvlsPkW3}6BKo5W^OjX zMU%P%hXTQgP&9el@bCjYhsOgXSk&x0G(F>==$)DlL>Xcl9-IqsM<69@N9ag!+#3jm zAU%&hY77OY1LLz%;{?2CA^7B6a2&%){!>9r2;8Ple<(081EIiS+!vS~o7f9B#uo~3 zo}{Q9A&G$OlHhkF%E~@b>rMdRc7Pv#kD7-FyofzjweNZ=z;RO(foMuc3~W0js6|sd zIsX%Z&;#7e#N7C7mw$YJz{5n%+k^YKz{FG_I2*O8b}8A_?!lR0Aey<_uX`$Va4Hl6 zfC6yk@D_OzAxwI6RBF#;2IFV+wjlAm|Ou&Q1mQg&-3< zf^2+_b`VR)8?D$M@);|>tMeT9@ zc5L>@p+IkxZhP# z)5pd_7;b#bKRe4!?S)Q{5o#d7P54n!hyT#j=p=N#u}K`OwTF&yW#CT?tZ4{cU!fxA zUL6N*GipTi zGn(Yp6Ugg0XcNUxYAGGGjbYRr3ulOhvw)v9<|nsdG9V{bZ8hH^!~&ln@f+kSu!IV< z2%HEkJT|TtKh>d8kH$JQNN@&i4QMo?p~h}PCxV(oAriM94Fs$NhZeN8$p&U0+6V@K zGCTih_d>;D`lxFqM&lVu0)Z#o1#)fhmr-O8~a)$+Y9iSD6wi zT})CNzK_F~MEI&bf7A#)TY@h`dc6d5gI9tj1Mp>IFjvQenZ#Ge1LHAA%{W$LV*%k6 zwZt&~k+G=>0xis|OPVGL!Cyxaf-4OKmF!vx*|p1LS7{bf z?`;rIMdQ7M>X9ai8VCU+7h-6;ck!I1~d0tBELfL2eS|B6wG;$`4`?G%)Ru6%43yTINap@z$$Mu2dcT ztX-W);EPsuq>7MkAk!n`XcAB-0~wP>p#G-OBvNUEmvj^_!6-Ndf8MAjIPkSI0i5qwrZG=XV*jn)OVGuO1Od+IE za+gU>7nt-&dgjsX3n3hiK=!vU96qT(_8gLZX;@xmawHIA3fdt4Hhftd#Dv*Jz{;aA z906UK4>j8CQd>N5iiAzSL$;2fiQrl<>giIk#Xy@$2<%o@P$7rh3ps6GT ztOM^ec%1T_fb?PDQv;t6_&x;HBJIVYbC5$&$C`_<6o`GLHd8Qel=08a&O|MtnK^De zKv?kMsA(!R<{zJ(dJ5R_^uXcq>A4C2USP9lf@8>>!%Q-o8jApvjM!vI8Z{oL=AcnA zh*n29q2@(zhoJ2QaS zhuKs{B*P`mpy!&4N2YnlOV+7_Mw4TZt#4PX0_E>j#d?) z!02dTVmFh(fGV1ke`mW?X#DcI3Y|RQ55m+J8@Xd*;*%#KJ&v^NorWISDE(xo!vr_WVpJBW;8SY>!--@fU`WQ>EFnQIT_)_pu&R)I0#gVCryJV0!u!#5EwZ z*zXS!K|nMaQ0H9G9}0n-D=-mF0%s7VKxr6a24rSqsx0n~L+J=stO*UmX3V4Q88n_n zL#xrSC>$F23n7yRji$;QvI~G`1(a0~ab_LuTNp;R%Uv2~GEX|MGNlrfF{U;kacCt1 zlAtRh9=dIktTH7i8j`2X1aZ|(wtl zvY5hK+)ArpS{|E67d1kc17T`Z*A+ll-!zD&f>B-HG`K8X+#HCk#shv3tLeHp@M3!Q z?E`zyKD75vh4x2vy;EQ}?3_C^1@U&y;fH~#=_jJP0sO!Y%uG+ug~lP$zzn#P1~5r( z0-O+Z-Zabw0`9@NS@=FYh~FQ8+=%mmAUKU6+(q%8&Wpukyfa7qV8?_^-nr>R`$3Wo zF}-sh2E5F>10b06b5s6cl->;<2Jis8ZZ}4@JP-iEbnLV40q~l)NY$2^>-9Z2(0Q;+V~ z1@&&qM-3U}Na{NX+I&F+{EYBR^3g*k^_y%P!92(_g1kO?M7^7o$~7T>nZ9f`fK_@gOv)hK6WTgj-$S@pW_oW`Aij(m73TH zc_zmkl#lT;UJz#ie3DuX5RM*7m%piRpGnRcphpj7s$XQAoKs@SAUw=lYaqX%VI)fq z4JrY#M@~15+IE(r=AfQIGn$9Kd)F4oO-w_x1(Z;CXaO~hMho~X*h+V8fn;CuomxPK zz8~r~0LWJx&LeqpoqVRDeA%wPeaxQk`pjNat3ievz}FaquW{f{Dar!<{;rVw*Xq0G zdxG5E`mW7qlc9H8-!*$JUaOZSeb=T9Q=cskR=z3O?PWRvYE`si(6jp3n`p#82RNx>X)U-%8-pp<(WNe-XmPBPuBd65^r51P|Sr&Y{SSJVSC+*#q8pthE|bgWASupBe_`0SS*URjVJ}6C~1nX+t=r zNO;tC-t7$O)=?oHj9nIJj??G#(#WGu=>~v%Kr9+e4?0IsB1IlKW-!BNhd84UC)1ZX zv`+r6zLk(JpUam4#Fm>KP>A~)WqtI(?<+CyYrKshgH4_x88EU3yuCIbW23w&J_DF`pAk%l zFA2=l#yF0{YxEg>MqiRQt(GaFSgMa1oF`^InEJujiCGG!Zt$nXJU~o4nDpR36Z1u4 zCWmxxX!D?R5>gpF2*061Y6NFk2?eOXY#bf0br8#LjOD`}$| z<62;3lN`*O;msUD=^S}{S#m2uc%9ePstW+e8qDg*nxrSGP5J_AlHQ=7q&->lLtbEk z-Lv}xZeY}d6Tqvx*EP9)JKVAgNC-4Wn%tnR0KD`ONw1{V?R{eEPzdxujzCQREQAH= z&cqRS;4ldCYstdn;CVXg=-9clXQ;1daBN4Bm>h?yHBo&DxWW{Fqydi3SLee*o zmoVd&d8?r=pU_}<3>*NxV?-9fG+e$!;uGl+b*}s=0@I(hnLh6K(D&ZT{pIRC+#{G>q&GY8asA z0xA#F9(U9<%fU2luYdfBsQcj5K2B0csP#{PcrB)ZqU6K{xlN#*Lfl3C_$~Zs0Y)tn_>PRh_b5H3_O+;ybnQ^o5*r8@ zg2>3DtlXke3$`=}4@goBI-)5g8VDL>Z#_5jbkw2vDsdU;4Z*1KiKjtp1g4-*JRPM2 zA)L*)*L*mXXhEw%zmhw(q)Nj5EqwSx_=Q$LnxR=@?!6?-o=f})`Cc?^F+n*ia)Frt+`=qZp7+3*}+@$ z7IuDIP_gXUe6?W9v0V!t$DUneA~|`d+fKDzvuC_?Siq8%@%FN7&Ro%1&O6JO8hK~! zH`&*cUroN`UECPn|dbK_H&BKur2gnH&gZuF{Oe}sSr~t`IO4{?XHN`h6Q=4 zC6ZfydhpcXg-|3t>w*Ic>a^XW^vNk$*6foH^49#{9azbt+*|4E`M+UULiY!0nb6c9m6V<9KiiLqJ}+k0 z@|m@Qy-u_@^Y-Rz&SKG73*F~JG4GUI^}MtGLicNZulDiIjnLJxD;Y{52Sb5vVHb4u zrY>4+?ByGKmm9YU?(Oe3Zd>X)lXm98uV?U$+rrkouzUM0Bb8tHC%%R5pJo>=8YAgB zCq|Et{^4Vbx=333N#`H7okloNcIOH9ID0bnC2Pc!Hr9zia?lbFnd&wW9tfWyn#n&^av~*<*3D(?|EmX;->z#B0 ztgo@Q7cIvu;f#h0jTcHUa2KmD=3ja)?Ase29uJQ{5uSb~{KT`@LHJ1b>fn{$r@KW_ zrfs^9QZph&wUInfm8xAa8SLikl)+@au}crb{zf*HoOgqT0d^yIwKJPCW!+#YQ#O3f zvE4`ozF^qZ6pkQ0{8ddwQmtXc9ec`xWLw2F;=d}E)` zxINs^A5Pz~%~bypZ2yg z?-Y<=KS-s$?ZzLZbzrc+uA{wMn7^*i1p5z@=wTi6L$;$996wr54;zd>+R({><0l*G zVb=JQ_6)FxQ&Wa5#&BUL4UTt<=wZ9@-C|7Y-7WNRDkD(UU>B-sD6mk2VT5{OHWCw3 z1)o-8wi6R#IjLnGF%86IiD@ySe=4y%&AuZ2a{d-yp8mZ&1K8gy(nI?5mO7O~StaB| z5!#pGcXLoL3prtuip*ESGXmeGX9VThV5h&YB#gn5YpF46Uy(wB!U^qk;D3!Aw^6e`@sZ|8PX^z_-cy%hTLjQ*#=>? zAW7*PZxK7(e74Voo33@MX%IcKPyK}%c!z8WR| z{P6MWTiFM60|9A@3KKj~=x8;9&)YE{eb5jjQ^&!d$EJXWNoXsW43aOC6q#fh2FK$E z5=clWWWI0W6!vSlYM|Cnv4Jm!OL&@eCDeHu7EZ|Ahp?wG&7u#7Fnzbg5VGyl{)2lb z{F|eNs8)rHYHU|PZTs{Lu=t_PV3C5;W&LrRUrfgEgoZBNc2#(#K^*zC!Kp8ebos_(hO1i4{^B4A+4`nLK~T| zutaHlZ0`}Zy3r(QmJ;H=kHL1LK?71i?hdOBaYM)8OJjsdHG^q9iLYhNzXX($ni&j+duK_@n>(@sBHHc`drf4 zB)(?g-K@cz$w_H8pvl?0=*5m(7Rr%_A!m=zo+&(6cD8Kk$yc5_|J22f*L%O!8!2#~ ze)iO}7agxVzvYY+7324d>%@%%{KkP>NsJ?P#f-_UV2ZbrZ6-4i`P7UXSkCK|!(d!V zfl7UPBNsk@dNT!lKMmn5=1ZOVy`}UOH{I)Et`wGo{SCGwtGB@TW?@HVZ+7-u785w$ zavI>vTiFJ*7Z||~3#qWgH?EK*NQ#Bum*IEw2hdQ`YMu57^U@n3gde)1O47VKuU^qLURYeK-#YCwtH;3)ih2og08 zp4UlgzOO?5pDaS#{jxc6?}z&!EH}IN2HfTH{*ZDvNOj8NMMLG>e}V)+q(sn01=tfr zP9Ce|fy@k%l1P#y60!>Ys?oTD2G9Wzt8jrynDp;gk&%btfMupsgcH>xu0ehYXY(-> zBgrCU{CtG_XZXAUsue=aMo^5EVy$9QE}xVeF4-z1Z6%y%DEoRm7S+x;I`iZ>bd9kl67Nf;@wClQ!G9-Pa_lvy?jlTtIP}Ubc z$2?!H5}EWc=tF|D@>u1f_p5axlLeY-*DN_FL#LlQ_0&?=E8EU*yA%@pM)ax^zFs*Pw1id0BVil)0S8SgV$pN5e7f2ot|DyFaK=&l0hir$6x z9J;HJxsuz#fa8rq8q9fT9CKRt3_uKIg3p1>h*-uIZWGeOrXhp;Ss;?JL9YL`aH~o# znjM=$ZZeK1)Ed${t!}=?;=-x{8f1WD(cpUJ0Ub2>4A=?rnLxCqC3=xX^1seYujA77^U(Eq=m+%{OD~adfE~Aa09&GCb)I<;ta5BHbPpeT3 zhdd4UOE7L{X6DFFEEUsGP-5g`LM#{r)7TQ38IdjH*|KZaT+vzp;+Qk*d8>!G%6V(~ zQqwD2&TrwZZ6FabnL#2F&#hP-qPdti7YpXnqg@|ZY%!$OEZEkE+4Yem%U6zk@d%(a z#7?cpTNm^%u;3}7IXU))otz_=2Vd z{W6=2X+8JF=lCRdBs1s4bH|^9w5;g){1>0+lZpTxo-R050G_r)bZ50MmMpY`=Guw} z_>q+|z*R9s)sf%bPG5G@-7U=J!Y&&)-mIg$+n6`&O=xeU!PMqAA}T7<{SShQ=+BIb zVDfoKR0OE%o=}lCByh$-o)HB#$Xt!42tf&@g(!RkDa-NY_wBv*l9_P-} z_uIpJVMR2CBK{OmL_7RS;Aex?K$l!+SPgs-r^S%AGL@IV!OC~cO40^mBxz!?l$CN* zBg%Ats6dS&+q`-o1?z_lu6eFnBUBMH=wpo(%He&At`DvestDT)W$Q2`tP`3BxqpF( z-2Vs0e0DEct8&lIz~UM1yq4F=j<~u114DfWL*=X)s?8n4g^qj1g-8}TtiH4mh;Kl>+3$=q0W5*I^ ze7`9q`H{qqk(p(oz!r&Y1<%4N$VSoA$9wwTS?l}2ssCzFWOKuSB_$6<(r%`b9jmaP6XLFbgf{Peki}lJIB!z=SUOJ}d*Z7HMK*_L za}>esx(j6&To+R>c3f->r*BziV@zrB=I@3st^3YX7x#w?`vlvzFuU!auP0$bzX}}z zcz>xQzo(KQRt6AjM+G?EFwxyc<_$B0_7rEgm3pI??q-=cN;1*Dk?uA#Z?rRDf0Hsm zm^Y0ynk~j0GtzpB^ly6T?rP(kl`UZZUXcO(zgMn@@bktxT!{;RnVhcLf|;5Aw`{2^#z%n5jbVtkfPG4ep697ne1vHNx9y)qi#sCP)$j; zsVUwbW29IOr8!$zOA@Qamol0VV6-5h-3lm~B)lmorVLKOZzv{vR1r7e#4S+dxasUx zbatRo4F-JQhIZ@}s^CnT?qWnw>e~2PP53g#PC^6QUo$af?#z9PK~PW!^YH|J7Fk&? z3PuRUFgS0HO`I*Ui8Bss&H%{y82mz6V8mEiN7@PVakCP#Bacm7V?%t(NfG?idVCL3@h8jp5gx64!U zrr+9`$eYHd;=_VDTV%4sO!hTPCNRY>ZaB8#r1NyvsjMaED_Q5WE;?V&`c_sf&kKxL zzm>1w3af)CXww5i_dxMiO2{NUJ0@fUtXoZAuB3a)n9Eh2Y2bKcGu>0cys^av_BS&D zy)tjQI#R&#y$Tvl2wP3h+a(NI_Y6Xu%*m5tHtk7yw~AE%eNqm67q+I+fWm*>lX7W7 zukgksBVzoUoLuX@OpJ5>4j?aSa&3^f&R^Pkq2t9(tZ$!56I62vYQOeOdMkeIKm&1| zJd-v8+DODagi|D#CvjQ85}QdA)MJ9Ee+IwM-$8nbv*;HN3#?0I^LaM^n$`K@j$=Dc zZs4s&V2@3xo6c=H3sY#+pSf)cZA}#|CA_6Xu#_F`{=kwF!#V2&TSJ&_z!`3A3hi{A z*nWKb0`r3D{ybw7Y0J}wvdxXy-dH~nTB-JQ?8 z=`^7|p9XUkuf+m%2(R@5OlM-UI_+tp9zv2w<%ZQQ;kN+4ZunKhF9U=cicUeCP@^!h2i--eQ6z`g+CXuS zLXC1cjn5?1Kq=^a#%t_jF)!5 zJSe1BiRtV3^mX5?6&nWlhJo;dd-#SuLi)pk`4N$MB+NV#Ta1Fx{X%-Vm|nxD*L>rE zSlh$b_PmqH*FGSmKPZ@oL}n<=3@L$^b8Ch4Ix)SOPj6mczfD~4i#Q%LuS>2-X1-8Zdb{dT^7`#TTu^}|BCPcZKmncZP#_jR4o(4$CH zn6J)=Y+jhnyMsjKMir3D8&1kFLch-pz0d4@pV|F4Y`kPeN>59&QnR#g>ER1|FFG#j z!|CnI>?Voq7q@-4@#2%;S%0x2T-YVpy2ETYk*xH9WaTfR$w9KRok5D+mq8>e-~h== zUlvGKIt<`=GllNUW!|(C$M@3dzI^6;8N~5c4&CQw-pXYl)LVtlzB1~qX1cG4d24+p z*xyd3`%0L%Z4BB|F{QV&Xf$()nNLhNF^gynQ$ox#^LASQc758mcKthgdYjSs4%346 zb`S;WzfbESg?V=!9;;VFl)&qrLHiimacUt=HKNvd&sFRMF5eJ#;$PHRhBmRdUv@%S zkW$$XEpd&4oPn9 zH6(iy$gw@-m>y5Oh@Fs^f5bp25rkuXB+aBx%>=hPQd@k!qZi!s>JMCKD$=TZhSYpG3+0| zWif(=lnpXm!K|-SCJ@{tLpec18r-t(grR2>eI<$RS1}zz}jCdx191gSr^3jHiN(>eBeMh(`p|tV@1gFfb)KU}O7&V@k_VUaX`lk338u??zvtwmS4*CzII zH??XO^(>i1Wx%ZI5d7=}d7I{k;qX9dPoaAz==R4Q)?G0MpuPle%yy0mzzTWr179yi6osh>nBwxR7Z8cAr7*$p8zgf4i| z?+O}6(NL#7Z-Wywi%Fnc_(`E4QPcF4ykAZ?J;mXSC(6ou=0Z47$Z~+PB9I1q25%!N zxFGN^@C%`GG$AReRA$zR1IG`1J>wg!P`FMk+`t!Z_%8dpSBp@QHTc z(d`jas%R?ZO{IdV{AlL~mgJZSZoOdJ5N0>Pb|p(PYRthTMPnW@%GWn#$*9KJ*!PbW#1g$ zk`u`*IQ__}M{v8+7X*8mXs_n&)z_RwqLXY*>J*#0dH8pB6Tdp%S$AOr??lBMvQ;S` zw<@`83)?`A7HDJriHF8q}butAYO!)UzCLD12ApAQbq~`Mv183}$k-40 z$zlHLAj%($6<7%se^y$^VXlp2Ap z71<`9ZCY;b5u11M&AY^k~nM_oYaTt$ohq#6`P;lm%tfef`vyt~~T=r}d zvNyl$*>tAk?RU zpe*gGz79%Wu-u!qk^^P^^oEhj*|K5;E2!qA57D27x&UNf>d5G>p)b4W?jq)LRw3Hk zY~3a76}BT49B*XP-5%zRoGP$`woG@G@y(=Mw3isstfIjrtN*KzBo0b~BvIL2nl6k0 z^rdHyq8XOR;-Hj4X2tq+6JP}-|8-k5oHgBJh(K`g)%u%!)vL(C}}9+1ocjQ;_|*9q_vun#i@>hjMN-trtwbd$2biF6mFEa(!u#b98Mzl1r_=hc>8o+2%`p&DGm1Lrqtw4~ajWojU6U=MX9BmhoGLqnV*!v`(bqkzH5vhsNXjrw$qmcwm$`1 zy3eWA-q2^YN}axT{{%uq5n&_DtKzs3?bMxG)C@}Emf@qm!M8|t$u_8+MsEUNjot|- z&az&U&!$-;&GM-&>NU5TeW}_oDu=?r82dA+eFpv__I{VZUaM^^*fs=4AK#&UeueGh zHNnY$*hbT&jqbZO;IzOs+JCo8If)R&1VK18Pz#c9E+HuRka3X}bd3xsaL@E*)xa!3 z?w`SIU$#u-{i#qZO?dh;aXTQX`2i4LTZ0*HYL^aE_hrbj$=9R*!}T2t={wL8yK`io zds{ldEoY5!ouiIn5UBuFjfqrp?$(nA{zVE4+|EtOc2|u3zuLSh*O&X5X>y(~4{)b$ z8wj0X=|z(>^(N!^?4$j^4gFYq7L|(q7(&dh>ZjUaHRl6zZ~ylhmyA({N@Sb*h8f|% z&$wdX(3(MNQGS3K<4wNb*%i<>&EAW&4KUFk-(e&`hzXGEK&byjLRG5mC)?Ia9XYRQ zz@pLvCD54r-7zlch$wO8AtigkTN=r+(g8gMxU5l6QW8ay)2AB%4i+)CI>&@lZ+FEd z9ka?Drc4J>1l(sDJPIZ|cq1kXHsZOjea6ARBxV(uhQX28H+$?rAYIU<*m6jC-QZJ1 zaBQ0&?wUDx2oC0i!&KoiUR?Skx`%jwD7fJO3%@KWo(rLnI8O9R)F<5j5NyGOTSBJb zcpta4WCsg@$fQcbV-MIoW@%jyP#+~Jh&+lmvT+Ak&)8P;F|<97h93=-I!Ea(++*+_ zzTXt~VhXQ<(Hk`$Bsa`>DkL$lD#gYI=Kc{4U{WZ~256UPqi`u`yM%ry*^X0089~2? z(7+DCZAGI44N?>AoLoXBuvxhuppk>dQ84h1)-maD52C38JRM#X8<>rq1}m*t;$pU> z^Wugwz}-f$*b35hJ!r6yYdxaIr~K1!nUyDBDkP>tMC8#_kiCz^H4{q6lO-6)a&c?S zNm?+f<0hl&bHORN8!IuMX=+lAV~8Jx^ zjKyx~o>tg$q>GOVac@s8Ty#8&K@v0G}%0Rv=LR@8`rK&sDTRrmX!3P{mN zv54jmoVE&OBbj3LZFqkToEQw%!%eo_dkAt9r_{|FTZ|wHgpj>g76>#g(kUv=Soxs* zcxOxO0Mu;BN$=j3zre#3`Y0ni&H|uPBCjIT(|Y1CGVqr_vr(t4!}{TFSQBQ z{120z#~ylV^y`&k(R#jUy^y{^Oz-B?yWiX+_Uz_+b_?kb3CVjtOin%Kd#O!KE9cY7 zmsCLy^Q^ot7M^iw9wypRmXLb*!C%xXTfTF9&wGaLBK1|hR)!3v9_nZ-AAYx)xwyPLc4{nAx9*i^UwQUq(dp__)nazlyV+HjQnfst+MX?KuUlWYp6oom z^VCi;x9Q#7rgsV>?VDfU`TEXxhJG^o{ZVmXFZ`d}dwTj5C{Q)>`Hf$v!mp==OEmrg%p=u!v>#eHHCJ>L^NwP{ zQ7Sq-yu%|ns^Ei>@>Ct&`O_3Qaw5VyMYe!v;qVI4y^eRU`)1=`H4AL3$Zq1`96+LBl!_rUPIyi>squ63UYkOh-#pjMa_ws?$GpA;h#jS5X^4g=XJ}Nf% z^3A>BzEPq1QGtC7mY(u)eamH(YFV~kEZxkPZeA|kD%d;TE!}#CUUV$3JMLV3@}%SV z>>p(FrCX0~58FHbPAX@X$d>SI$r-O$+RT?WU*H6`O=LTGw&RlL?V8Iq;Rn3p1AhJi ze|UUS=-hXe-7gopq3P)0$0=D*SqtbZ;K^iX+5}6@N3NXHrk72N%un+h7klw!4p476 zQ!VKjTv-3z#_u!>MIG;Dc3f-dhLPYAeO6R0q8h-ns%MZP?SEze%CEe1PebRPj_T1BFpXOb4H(XR!{>?n9py{Tb z$||~EMA;l-av`5wc;>P9k{gv0Msf-kS*e)rYSCTKyX!Axd^hbo>4JO9;`WaknqTw$ zrAH`k|4Yx(gJ(LTQAZzTl^@N+xpT`aa-tKAeS^_+X?>_d^FvU8rZo`hFP`MKV+y^%7{x&3GN zM?4!MC6y53{T+`BRsMH(JbuZ)wEdNV^8*(PFM6+5cknwN=d1is>$;4^!81+gHk{oc zIIC`DLS0vCD0}wx$7pqt^o4nW&6bDR0WrUU&u@S1=7r z!)#tmDdtm(&m7@X>IKufgz@Ff6Vpogv=Sk$OiZif(<+6u>Z3b9wq-BqBA_Ay2i2Y2 zvur6{Dte{veBGt|<;q=H_kV+Et|Sq~A-ru1gC`qKZ#lJP*;;koMZwVuW5>qM*sdnm zeuOyqTUPlyZ>?IkHb=U9#qLqQdvuj|2drkl_=RI%IODpS;)!hTU9_F#PCtL@c^(fU z-nddurR3bIq(H&OCff0wZNXkHSSrFy#SJe*We?Cdj6iBYMHKMtjWWP{H|&6zZsY?B zz-41cC;jP-UK)=6{q%;Ls@_Y(!Mm`a9C`;R>X%X`H_*rFNeA->op!KZDWWHx%$4FY zu)ksIX`9SszOU~r2iIR$(v!K&UsrXS!11Fdda{7|QFB)+IQS%bvWVf?47BH`O_nnJ zy3QhSEN6Dw;C0or8BDP}W3tjLZYl+Pq|Ab5a|eEo>T@US@}mWv1>m^0nVxK7u5IbF zg5zU*hZ$afU(g8Ve{<4kW*X5fAa*D#*h{h8^VxMVC5gIc6di`x-howX+sRPrFT?L< zKWu@Dslxtxb_+0&DtWXNHzlQ(I9-0T99n&Y(vgPmJt;_{em~YM;gRPm@iX7!2*qaFxx2kiiZdUl|ykfUzTlIK$(qq$pqxa1mWL0Uam zM{8e#EM?bJ{Z-S|Xot01CoV^gHG(zoRa*^1RB!rTs77Vhml4J6*AQ%3EbZgL| zKff!S+;TcvaFXGz2?;f(mIPk9Z3{SReB^X)TU)fowX#)fE1z7qJJnlGea|1GTw>s; zG)D&&J&HCr9A%e9;Q_=U3U*Kt(Vgt zQr6^UABrlXMwC_EJB_zH!d^{bP1wifvR=(ywhgt)Huddu%6ZDQm)*CG*>qp}oqD3z zpsZtkAK;a&buW?6=Ck|KyvEwNjS6m*6~3daa{Ndc#Tjv}rFE>p&Jy1z4@2-?X#X%bQMNUVv71fw)VlO$q z{JyPk=c{$_<$u;*E~g$W@a6dmYG5rkc=vjv*q1y*_gil@=4Y?BJFedD&t30AsCQwk z-g!zdk=AT}0X;QCv!~j$$HT~jGGu&tGGx8xc>E5lC&da*FS=_FQ)fyjPfvK_=14;c zpTHUSb@=wL@(e|@cdEIvx5r#_)4^&kz1UZ*rRk&Km=cwq`9aY9kb-L`VAbQUa8c-m z4CVg?XfVfk%s@b$UPWwJ>ie^Ob1fd^w&UqyoM&&C#J5&ueMY z$*tw4AZ?94drxU=#PeDc($?tl_ms9qJa6)ujz8VPkdq6d7eYM^cTqzIM`RaU?r~~6Kgb`8){&w{D)oRVWybigZ5H1zN)xjU! zs%rFA`s#2d5B^T{hkRgG6|DEwlbcSf!OUo6g6n+iFeHYM!YNv0aPGwFFPdX@ot!^t z=>Z-f`exNiyi812N%5(;6v-FO6szM>d{Ihq;9M-_N@WxRZB^;G*9ANnoSh+42RVgc zgDMI0RSw~zd%}|#1E2)6T+#*&IPWF=9%!3mjZD;lJywppjbdE0C(nIzIBLu_N03}Q z`F&dt|21>N~Mzf<-`68uKZy!JD;XU4KKc$&FN|S*1jN7)N)Z zIWR(f&04+Ao2?y_oX-to5g+&6sLtA{=V;Tb+fvuSYD$k^JM~;hT_fh|)V+CHbivmw z_ae}?%#rhg`Dzu6FO?h383N3j_$0QFH^1$>psS?wCj*r4HbPu-YI6Q};lPyeL;K~J z>RV1jerwVuHHBZVj9|TQ{b%N*HuyGx)@#e|R^_6(M*#b@_*%3$C-6O>)>*cJuLeXQ z)mBB97RW%b)z_*;@4;7tlK|goIeqo*EvVIlzAea4WBRtOUN`X>m3)G2!4}A;sfgNB zsI|W0%ki}}$L0pN;jE3mjov~+g^Cbo`2TO{LmM?IUa{QDa&Ps&w~7{&tVs)Cb}BvI zQV3I|K`+|x+HY0-yk1UCeaj`&?0;&^&yLwQsWi{G>9h7axs<`p0Q2G)Zvax+;1=JO zI3A=yq1!<9fDdU`+%S^By;*U)!QH5ZRef9Jyns7sjL}X!<4WzDpbJR)=9~&e*(T!` z=yU06pYtXAGJHk8MsIm7xu>cx8IMNJ=juBRJf?G!p$v#6N*`~g|WxlPe@pAzj))U;?v32mc^j2?yP;)0bixu--y$O1X zPfyM6kLez|@qThp4}%NhC){&Ee<&1y|M+7{;H(q4C_Z+)uYHK01ltTS|lF&Wz_PG z+Tp77@%^C5r(8OoBq;-ixQ8H-U+UZyQQE`p0sATlBTNW{Tfh=qLq`b1h@5d~Ok`zv z11t!<$t9TF-$Uf{G*R3H*zdwp-vF<8h;t93FCOcJ2Un@EEbjbv&xI$?4gSZ$Z}-Gt z>B^?za>{gn&_8`76c~eh;3nqA;S3o6-oW%cEA^)~_k41<3PW%v4I&f)Xy1mMKS2t7 zKE97_l|1_4CEdG(l0G;B2h?!qLLMDlJvlc%4m}yKBtt+(EouvfenTMRMiC~Df}stm z@G3}^_g*nvT=$CYFKkx~pK_oDw7Mlf<5=Maunf{bAuSiBL)xX`u{2JAnluJ{?`|5F z1_4+;<-Cy0e9g|C1F|;rEF4$ihWk8&K|~gl(ycNTpvPP@3D-JKd)nM^v>j=L3>AyS zsmF$*IWwSxl3peB=i?(yNpH> z@k59dqp=H}KR^ShNDNs4-x@nL4`CrGCjS%Kkj;S(1zOsEPP)$KD00;a)FK#T4(@UMWC+ay z#v)B7ji9X<>&IyP5R7Qj(|!)yI%+;R0|(QNP0SrU>JJPlci!q4dEipA} za>9sNR*yzyVlMC`Y7xiOmZQnwo|^*Hh@PQ1&12AhCby+U=`jwJqCm-8?MYk*@qqHg zk|CrfYJmj1(U*o3R#2H5{m4OD5(1ApHM%1ljq=3PN^)2s75|c~ zXP%DQu#1MqW@p9#uZ%wtO$M#zy>Nin#29{vro>`^E;nLu(A$<#xl)Q`|M)B%JR|30 zjybV__KB&`!6_ub^4Db52Lf%JI0`&Ms3(x!7rvD&#hp0LkbklBn4AHjlqr! z62&Z`2?7th+yn~59V!qdm^DOgsuvWlfqMc=_cxG7r~?d5E%y8Il-Lq`A}_*y1G2da zR61m{N|+b!7Xb~!1IG@)896UJ^;H{O1jVztFYiA6@TrHFJbYf`H+M@~#P5{+r0)B5 zeDlKs`v@o!r=TLS$#}E_RGF>Gl1{I5_et#dvqr(z6lR+~PAxlgn=42slB4L z_h=6&*k^6Ha71wRh^akCcU*I%zchZbO2{Y^9OXnc8q~(yPQ@-u7E_A&l%nO5Cb6WI zFKHzj$6I;0N(#^9Ty?=9@hRo4rAtoUS|i_;kPGEBWEkNvAS;z#EIP}0XPMxvI65G! zV5b)yGc6cVfwS^dC2IJ$oY;7LqhKu%t(e%?_lsq1d|BJYNxrOGu=YUJb3lP#mu_A# z;ZA|#Gp%BA6JOjU6t5SH+xX%(Py!YTHZ54;Akq~Rl~D{Cr#g@Jf0*PraqwD(djT#+ zE<8PdY97yNn?LdV@#jHjQ?%Fcb~w5Em4oLGUVI7;uNLgRkdqsZWYcBa7EoW@nXf3* zE_8p8mifNj1-^yF=geo#kkoRfN6c*IGn)nbdePp_+uN@>%S2}r?`*ng2KBsD__nTdR)g$DzEwUeE7T}lS(sONR+pamYUv4_x_Q!2s4~k_Q z__7T`PK)4d6`fmn=ay@(GSStwOHP|Q70B{Fr|rc0PUk4=cp}5x##!NFNoFACJO{ep7` z9PGq9A6y<96^F+7p|SAz6hAa2I1fN8Vbyh{>Ts-6shC~KXIBc@)r(0VWaa~$q0Ngc z&sCoVCHbZcMd8BcMO!2-8&2zk{|KJ-=i!~6rE;;dhp+5ecJ@Zx#pl?w>{7nqZdpwJ zxTy7F%{#e5(eA~R4>HRjy^o8___Bd-jR|D~Lh(QtHW(PGa?iN~XAfXgZWeRa^EvAU z=LXTaiFa;V-GWeBY%Qi7ip&(IE+#{t$}c>VCv63RjT~ZOJ73r?6mAyswk(<>cK4Zn z-d-zZQd#{<_W5kcWI3ln%-O={Y!RGWMQ1PX?7ily6J48l*QQGuysJ;nr4n-~Nrl*u zO<_qGQ9?Uk(k_&27E3z$l1`zdTX6R*CSyJWynP*n{D-t+KCL+7tPq`e-eJU37pZD^ zrTu*SwQATEvQ?<=fWs8mWnhbDuE3Ncrxf+!3kt(bj@)q&Ex!27v1eZH6?1F(+}dv% zU$edngL{+6cJXZ2o4s#uzq~#C;3ItZBLX`Dh0Q_Cm~JE(Cbd9xl<khuz$lp(gC!x@_%$uYkkhKkmD9^yS68u)A;Bx{ZXu^Cnk?SjQKx zyRaeb-neXShyGJkaxVL9HsI3bv|=&M!>4(^(T2Cc3f7GuCOeJ|zf=jkRSupyNba#8 z;OhqX`~e|(kc_%(cDHD+!}oUjp7Y_@rJ9#t#Bdj!uG|(YH_o|wOcN+7w5y({bBcxtJa;cN2R3gI!o2o zhuN&37S~~nZHrUaGHRCgU$hDtJqvw5P0wAVBN>?|O~ngV#CVS>Mt|p>#vUQ@OMSj*E*_*8)<6YrJYR!p_n&Gf$3l5eQ_@duct9SZ=WA%-JkD+j(dE#fPst`)(Fe8F_Gi zTuJ@UDky8-$L?ajxbs^*LUE_y?hIS=u4U$gb8E!h20phz$Zfis*(}vHy9%)`mg8Ml zEuKHO9y(fC#koh$;#s51xOHtKpSe-6w~O{p-rjl5StUA~A@7S>yt5NV1R*?SNO;_~ zur(KkZ=O5st{2@cyt_qkw~6k}ynD0Y-YVpFEDWqsjFUC*rIoKL1qjW`tHM?7VpSJk z)g@H*2xYxtYcaqi-wk)6UDvHM?4aBTk2k#Rg70!Ui~FHF2P^Xd$r*&{f5q4$>{T=QYG2{cN6wV2lk zJe!c$EatWHd96a;#)UyRH9WiGR&r9g?S>U>iyhZZR9X4C-Dh`4Dm-FE8(+~D8TN_8 zBkvB62zjGmzVqan2hRRdn=cH#Uhu7gcWS~99})_l6tkXO=>I4s>tqFN5DJ&K3n}fFvfs|XoF9JR zQL*DOzT>fvOUn83-pdA|yjLjc4ciN^AuaOpn}&rlpHS=r{2c)*QF*>{Z7R{qyILKFLp&Tv%^J=?`1Z^tr}h4_gcK+JtN`qC*RxiWW-e~x|(@c^9BD^R|^ci?8X&4 zRaGxI>i?|t%!7-CC#sKE|55GvQr=M?DXNZ?Z;TYTMT%NhEJi%5-Doi0=wX3fh6YIP zgH1V9S}*Li0lxG`25^!$?7-x~&YT?Eja=Y*ZsY=gal;8rz>NwZt2a`C>fI;-l5)ca zX#PeCpt&2vG>oMio1x)9g)2nzAEiINX~G6hrLsAC#R?Gk#jOm=ntqd_VG!VrthQse za8~c7CoheMGy6q$2hZ;K=j(d>`l}l(mAdZJ&<&Uyzzreu#WdUya=0392-ytwD+To7 zI_8Qy73~f5;RfbPW2X%q-=pcn&CK_79TnhstCBw4VtlKr(+Q4uw$O(+8oy7GFF&ZD z4{tX9z|)ljUw+KcM`-rPh77b9bhf~Y&!mqq4B$31ID{hlNRm+~?kWO@XrPanjiRw* zBRJlt=_AR;_jS!^Z|`)$>zb23VrQ!z<&>cGnl{Eb*6*kA96~; z{J)qEjPr@R11s|18|Wih%ztm}uz=$qb(q^fmi915>ZlbDeI%DzsVoQk4M*jX661}H zwMPo-)zVR|65l2NF{SCxib|UKP#Y*)G$ADw}buXZcO6m6~wGh2m3D!45a-F zQ}%p5^NV7v;xFoGG#iN7Ow1NyZY1VrG(V+@3D=*1J&BlRVkQ&QPRul7W)L%rn7K6M z_-TOw3i@eD654BsPn~%_NB?Q7Yd%B&ONRl{{AGp#e14h3f_=Vj?fp^eHI8)?0U{=V zN%1(!$>j$HqhE&K%^ySOi3xle6w4+)aa$^4OKR!{5zXZ%C6wkeAi3NmPUR2Fk?_P0 zbwiOfJo3WMZ3}sDc;=lpNa4XS%EBEBN+)p4~bc0LM*D1l)e?viIB$pxG70tI_JI*if^&~KklILx$)8V-6SiXAC1IY z9E~ez;JPVEN0P_B4bJmPQa)q};x3Q{ZO)Bx_A z5c(4oU}M@Sz`pKG*hMR{Zk~02JzFen<_nP%D(rl-U+f;?yGO!flYIB2!0rQ@6@T8a zIW7Ji`6LstgA2OfvwobMdB*e3Q(^xkpS%xt&Pr#(FK{oIfw3_peWmw{y~~!oNNP?b zB^NgMww`ST4q@M``^W{ax8Q=;B;bhFI+1;)5RVPckGr$c*;Ptk&Y-&rnai1-G&tUH z(Ot#N8(Ai_7o*QTq6|)HW^I)55&-ev2+E*VT}yI5C+Wmd>+fse zuc`?Qe8x-1?ovId41B}ja1_=Tl|jl~z!CDFajg6$+Wra*jj^J}ST$B&gV096=Al2A z$BO+oF;;RGbT3#xVvL`2JY?Z`$XGQVSTY{qF8a)lk}j6MvXSmGGFRG7V1L6%gE?Pv zFC{*4TR)e7(TGYE<)*i58#sg+DwtKcmDJx{50)t4lH!Hs-g9UlG_WOo&; zqP7gE*5M5-hI^s6UR{vA`_S9-rTd_`N%uAIRzVL29_uy41|BGFC=|D8cxK3}W z$X3R<=bor7iL(~9m9GG}Bejj1f`O;U$iY<{Qhtqt{-cgr0*<(fuvPD-t@2X#R*24+>#nsEK`-9S2-ccBC>OE!zk{rn>h`92XN-j+R zowb=4bUj)~^jHEdq z9B(w!-4yf2dK1{+q-Zeb3-5r6q*J6;-TwuuFB1QL62Kb6LwCCsO5x09ZuU3DziTPw z#jEto`6u$TcroFfczOl5z#7y9g4#NFeq+K?iyKRiMvLY7j!KM_np8=yCBUF zP7etaEwRukP+(Y$4$k?f$%$Ls>kw!&4&ZM>K*BrQsSMYNM~*+TdH`RuW=Oa0o!JiS zdBj!BTZ_-kisdal{99Y(HNFg7|^@T}&$ClZwQo5?7azi z8`pg=iXny|HUc0P?hCjR6c>r4B$8T5$<$8Sq+rXY6NM5fnGyv`1Io7OIA-nMgo@LO zjGanqe4pssd{=UtFP4+MYqia5WXZAQB@V`;7%(i`N}JpJl9%RLOY7R*-tT_~GXsJ{ zN_O4e-ut~FapugKGiT16^}qiSzhLGN&I=eYe5oGz(hMskB--%=em823>V&+NZ1U|* zNSf;Gm>+FcCRLwaei|bwhmv2_=5eNEz-7`KAqkdx%+#>cn=VPudsCk_80NG8s@4vJ zjt)$?84xh@f2w*cmGe-jOwgVJElr6j_h$J&{T}2f)B8wJh8-X*Kn6bbK&n4A4d9Wk zcT;tt!nD-S(Ni|NEap2SUC)Lp%24&Gwdatk)2}J-*2&SMWYLO}{dT150Z~fkejmmI zNjn*$~1j9lNk&s{!KGvoS%^#NN$;d7BVs5 z0lFMQ5Xeqi@ffk-H*|{zu54BE6vfU_z*f1xp%^_F=b518Z|V9~3I-@xPXT)&ePiki zn~(;_r>r1W?rF*-A&6T>66BR|oEr(7kR%fyOOdc$cZ->7Eh8`W*d$~oqk za!ACls}Nlk@HjWV0hp7!;D*lz->6>1;2YIruoT~pZ&Z)>SnE`4EX#c?dny}ziRVqv znK2hP0wE*_Eh|X?< zPgEc32bwsp6`!aJx;z{_$#vP`Nv;a<3*OGfT@L;g3y+Ie9O(#e0X9KW?co`#G|dLK zO5cR2T>@qIi~`8%CV<}LeyR1nyqwtx;IrfGHdsrmJMq$p)5 zXWiD+N>J@JD0Kk*##mPB^LnkRJ^xu+(*bWLpS3le&;fS^otU+Mi&>imKNjs;&6OCe zZMl^dgy|XQ;;U}8>!~9|{qp&HLCyakZZ^2})-1NK0v4k~kLA{Kg zwxq${mTs+S5#REWyr${wBi>*pMgr6t;R(p6*AFvB0pTWI-MvKjS-n_bD zEBqalar`3d9NX$JLR~|I_o}rdv)HjS*|CAl+u|9O1k5#P^k&VO{3cOMOxcKxBFYS z(fSxm@}ZP>iyd3d--8}cJ=8LMsdw<5-AY=|ok~nQD^DtC-91Kow^e&miQO8KRmz^# zcg3)&^;vh1l3ErlW_wRBC+y8-4etyb_ne zDkVp#y01^UAFK|RC?hvi6Rc6*#9*nrl2<`yqpwCs-#-ux1pRt;VS)v-*HGnM^&oM< zGEiW~`;MTN0oa0LtL&D^#IB2dAKrt{&%N`*QjluXhSeKzEG*UNV1lXb+Xy}2yRE99 zIR`X?|5(oo2)xX>{+u#BG9IVsbwQhl7ZviEd;hbMe^hq6T?|nfTL)sawpf{UK zl^n{*S4V*t-|#t=1?x{mpGTd73+l0pg&MJ6EM%Sy0pE~?r(P?qf1xJCY%B(gmccq& zIki#Mj~jkivJ9H3%|p$7#Ls3&uqn8J(t^#n+rVN?!A3gw+S~Y^emPfisI*mZVX%q8 z#$AcFx9QFQzFo?*`h9>@os~4Wus5^KIN#fqHG|p2{Yjz*z{wIG>TUxo>&MK|hxmgo zw(kH|CVMDz;RV#zhma@KBE!>I3*N-l91t~Z87-c%rqEij_;%;!=d>2w_WS;~UJDj~ zj%&fEENjlS;8T|Kzx-OD`@NVCI%@%}@wxTVu|_!C(r5al&O^H8__kzQ9r8uL46 z>;4sN!PvT&AhdM9$U+Z7b9Ws=Q+EtudiOuEZ~!6S{YMFonpO2ZSFgrqq%o^5S%*5X zSTMsb(A`!Hja)b;%Lzj{8!Qw508BG5p(W<5jwBOyLNH<9z{o?8NFd>+gjpA1BbN4E z_^AiQiBJRwSO&wlKs-~SdNqr|P+?%ELIRvX6k_485rpYM*(i(fe(#osz7v z4EOKfInclIn_#(wX)7H!oXL8z@cV_rQd|hXMi5AF6KRzbR#UKwf{heVw+hU91EI{q zrqnM0sQFop&1eQRgl-fW#%c_%Ama!!nDf`vexoMft^n=To@m38TF>GD+yUZa3D_j!u_zZ5WA z7h2!29`WNw+^W@u+0l@>ucdU$zUzUZVX|&O!rVd`npn6*Wff8|P66v;W=mr$U4qCt z%hwi7x#}jc2nI<5n zFAOFm+o@m{3_hW3*Ac{R@?hQ}j64(;D(OM%08CoItc@@f7OE)$24{9AJ`+|zW*kE{ol&5* z{Eid@7h&N^%KHr}(wme`HH4trL{O?h;)8J~vhJXTL`i*yQq5$@ggzynPKF%F+(yC- zhA>TUnW9W^+_)#nyCE64$W#nW$;Lm@cI}23BH8eOO$S)tP^%obuufne5m^-Pf~|*V zscbe`9;X-ZWVtH51U=&aOh-aODrH6szd_lDXbwj#IucT!!bk%NMx`;qD3igF9nGu} zGi#*G+VQUUZCNwTG(eTW?LC%1l@A%L%gz$X>`%A>yIkB5Ep8EuTcoVTl4S{V`-Kcq zXRYY0Jw5RJ$a5p2bA@DCnUEVOi@NJYcfDk3xM4|mlVFzJG`^N>AY>maoGN^(SapXt zzVWKdJ-*>ePX1Ge;E(RzgnPK7%a1OPk#PsVX!lQhPGvuvec4`fB`fE+krXP$>J^u> zR!(%p?7pbIP_!39E%(^K)Bt?0!7JLCju*RLfPsawlMkMF@T~F2=07#ZDypLutzt!M ztgwW99$vGg7d!sWke=zhVKlf`L6;9s(6S94-}ojpLQdYXy;EchV|F97iN$SFR(mvS zotU-m4Qq7$c5(f7DeGRza-Y^J0nr&aeXr;wc?rE1A$^Z@=6(<@QV`856SK;stcqw> zjhIy{1oEGyUxpN7mv zo#?KUEcNJt*T_VLjn;SyQxZg;=&i@~w>eHi^DXZ)}WizF*vYzvTOZl=(%b$jx-Wnf(Oc zi@7i4ik>zpv;Bii-;wQ?v#X-ni^S|jQf3pXNb2k9Onu#MsBb#c9&NruY`#NkUKefN zBsOo7nzuw2bVtm^#}A!+_{77Kx#pVB;LN|7Z^$qDwh#U}-*x25{^V91U2$YZ%w8O| zmx=bW>84XFo?Q_wZx+j&-?q0vZ`qlL4z6gAmah`aSG`>hzl9|e=BfJ0#m6h&_2f)I z`PY5)E97h~<|>Q2Nb7t$eCjLDenplyeB0G_Bi-Q5C3W#m4g)$jAEWmDj6Cvkx$(qC zrtV%D^)!o~=F5xMMHlyqi+d&ewnSqj?nFIHMbFY7@sSlxAt?v!SGxJUPBy+<2sWq6=OToxVs>z2vOFvY_El7DFd?I$LzrV1^vOTeMe1?KPsk z=5+Sk_JtV9B@0on*7gWr@K;mgIG<(F3mqbqlc zD|bq+T?vI|D2~&eXwGUeXZ6e8NY{3$<6bG}J}IjYZ9LNc+mu+cVWy`F?~0T0z_R4Mc2YJIp{OAN<}MLrLy|e z#%DLmHdh{@Pxe%BjmSS{bchWdZ#S%gsRA;RGF=nPD?M2}-4n^JnM{A*QxNm!9osy$c{=y9 zw>DN#e6saK>+~JpUNvdHlE3Io&DqUT{)Wkn4?OvkORrSdinSYF%8_a}NYxu6-U__H zR~e~U9<5m|)~r64{f6gtpH#C|^lrt?4|1v_In}Y1-I3hpGhI^dvTFr~#YQgguhe`ODmbZ>LXW z#@xPRfvLc;#^b^_8z(y6%_$|tfA6uCQ!CHxdU5FcLsI4H?+=|xo8Eb9@H>Mq42hMi zf9_cw^OnkXSf+QK+WqYAx4p2r1N#%0AD%MkqFb^fd4XtNy_i>jde`&2pWFSzT|XF< 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 diff --git a/backend/api/__pycache__/imap.cpython-313.pyc b/backend/api/__pycache__/imap.cpython-313.pyc deleted file mode 100644 index 32dd4ab7a9c8da040cc346031eea0b0c5b3e5f67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15526 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


iO2_LNYroes+qLN3_pesZ8~)m8 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 diff --git a/backend/api/__pycache__/invoices.cpython-313.pyc b/backend/api/__pycache__/invoices.cpython-313.pyc deleted file mode 100644 index cd2130e1b3c134d3f97f361318f62c0ec8882409..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 173552 zcmdqKdw5&dbtii81_6)&3Gn>_-vr+jsTcL6NQxpUk}@G!vPhYRNQk0Eg7g7tNsOH^ zaoSKxIwIpFqEfe_r=E&Ra%-kHooL$LuXf_b>YaNVKwu0QQ5?1P?R=g;ZfPrR9Cz+F z_qX=JIXHwsNzSC--0vPy8~g0X+Iz44T6?Xv*SVjZY~b*`d;MqQJyS~VALvDV(xRUG z&ssR{8=RAKDtb6x_*L)<_O9fW>{rFB@T=@mcWZb}x0cu1DV?fE*RALE%&+cA>NfBO z=GXKXyG^{QJDE@JPT^C!&AhqW!dtpi`BWCK?Mdsl@>b^8_1L=8`E=&j_hfWu@|oRP zd{%chpUuLOdUCpR`CR5#^yKk*DA&-F-(A2LurOm!VYi*PGry^)sJoai?k?d=*n4tM zX?Gc4#{4Ng<=qv01@oJGD!U!LgZV8zRo&HmHS?$T)O6SKwalN^Q`cS3*E7Ghr=h!% zZ?tnmrebb;6m9&rA=SWH5t*^yVs5B;yFz&9w>xb^JDiGI9mhG-hjuipof$dYvub`P zT9JtocJaGWLi$ikv(lNxV)i(*hl&xh7in`?$i8UE{-M1HRcZhlak(t+fHRLjn8g)y z&iowCSum8lT@@|4m4y}})GiA>#6pV@S}Y4~W1%GoEtQ3~v(PexmdiprSZD=8D`laF zS*Qb{RkF}d7Fvza8d>NO7FvtYI$3BJ3#~_JgDkY0g*GB|n=G`4g*G9ySr&Sfg>Faa z4q0d~3*Cv(U9!+V7P=duEwa#l=YMeSL4WNXIySU}@GR>&t#cn?{8?Kj$B_emf-ff7{TpD7<#$?-+7A52Kv@tej5x_A+1kP)D=H zc_doa@u6)4-w}FL?mD}MI-K3Rl|vTve2+NVhLSP%syDqAh;Pj*=h2~5XYY_%tT}4k zCzK+M=qH`1W3)#7s3YnmVIUfgTu-pJo?uuQKZJNQ z714h#C->0W>YYyx8KQk~3_bxjC*adCES;2mPoa*d@ITrgz};Y6t4?i!!=a&mhQo(p zP3b&6bX0<^bv_*jdvJ^P4oCav;jm@n2pD`O4))L%u-#&xJ`{sW`6vbSGjY&QZvlN| z6ZF{lQ80L;=x>aA!1KhxdwL6aXCD*ZSRA}zznNP2wCo*OzW^_<>09tYnI_Rq=oYXiF?U>1JvMwEGydFAn^cCu>5D643}qzh-{(F>|57R9x}tZ^ z#FcPvi+-5>6eWnzwa(|`ppS0>{lX^b@$>T{YAnuCz4KBW%x9f*&d)esaK7mLtn+hq z8vgtiWz0XajL%~o5X&(*zYtf>#1?R0+64D;=hheFpicsNqS5&~j|rXj!5H3uHx9aY zi@JXav#U#-T~5VA=a&jQr?_XuN?M33X=;mhd}*lbF=pB2p>A0#z8nX8dJEWJc}yH_ zUV*+E2mLulJ^dc2r++7-p7<@wydt#ckvO9=X~vxf?s$f25qAdXVq6V;TlCTr$`YvU z$6Et8&+xCsl`^wMDKGy93dfBPi0tZfC?o1o_lzf!G&4Tw8TCxe zxE+d!uH7>-KIxu-SKmK9Gd|^oUmHnkJ=QfaH9OG2rn{PkDVFdT;ez@{Tw?J|9YQ zs3KaQXTmcw6H%X?^^Qc;GoFhxNR@QR?ep|bp^kXbj(KK=N6)~gcKJL!nxX6RK0h@+ z;)x_jJ6w`s z_l{8>3&9_m<#~^HW_XrLP(;$F`Kb|9a(G-QX&5LPqxzet-Mr5;JdK79`^M)eD7Afd zdSZM8m~HpWpe<88p9*|Ow7h3#miLZCSrd%(Rl_BQ!Tm48`3C3WDp@ecjUv^U!l@jP zr;f(CxB+F^oi2B*7 zac@M!@Nl6#1yJ_K4X%Jw6mgw6AXZQY76g)@4{$7v6!?h4}BQhcnOp zqtP5Tr-qFwS9V?Awb*{O`)l1V9}U^c1Ge&@tukb*3)t#{wuYdw(XVd2k1QPZ->|E% z>-cbA|3Gi+(XLY+n|eo!G^}^Zfp6-a0m>EkB<>A_Qs2fs)7!iXm*Na!PNun3&8kvv zQ0fol(8ndm>pq1vBGQ2yssm#vZcZsng_?q)r4aklt9GfKjAbRlakp@s7>3%YohlkG zQfwy24JM1w2hE~SOeew>fBO|KrB{hqH9?85hfC#Ei7iFV!RCyA;&V6^1J?L7oW$}Q z01o0GarAmMgBfDnK$hr>{T6dBjaX~1)}@tNTm*b@139sjqEAYJdOJ11Rl-zgy*AcL zhjumvqu9_oJ2O=~%I3eF76&^m4)(MB)TG^QpTMGI$0V7w&-!Q{&79*s9{bF=hxfG@ z8td%GvFafg&myPJ+Ijbd;gKot3>Kt%*#xjxdg{jN?8S}SE;cqaj1|||i<=u7CMSv= zhHZ8B=u&8(#ad@SJMNhnji#r7eZ@VUNdMf-rG4A#w%h;ycfV$Dya?aW<9<-0}m8) zk>pMAB1u#a1}~B#c^SM9fI!ZX!q?)5uOo*hIG+zEVwB+`Vw53+5v&mvR+WgF!0~02 zrXp-%PqPvhRQHU@i%koA{OSyzS{~6w*YHhiDy@oa zO}zsTgdW)AdN?+>66|T>3}m~Zs(^DT676l6mkIJpb6x3DCffC!>UwT_G@n=HQi)}` zjaUJbP&xLvR3e;Gh+9gzB5qJxe|4N2EwN({8dII+_NfZFvkHf1uDavm4DTM9vCmA+ zxF;}=$2`~^XT6w(H2wSA253UtyPf@gbv#v|k8gk*(NIbsXf5@#zA10SG&RBx(`aXJ z*frgwK7I!!R84riktB)ma;W)(2a%3Rx1otlr>Bh z8b%Jj3+awfhSPB7xqncb!^X6bu@HY@vt>i6(WHj0nO9quT9(H`1=|7z+k)1n1%23( zb=9nyllm^8sPOxV4;u`(yuUS@ zdoxo(ZnmMdT=iy|0`8`PLi-6DC@H|vV-FOhZQx2F1^BHF6;PJ<7QE(>5DFXLO41ZoFrG0G~}^yx{@B! z2E8*$nkt5Vr8CKEIQhpeoimA+=nDMr#d2rFRB&k@m?EVd=QR$Rkcd445=kM3U~@IN zj451`Qk-gb^r25#2zjU1*lNVYW4~t=(j+s^ebI1?2yeT5l3>DN?-Ax6n|0pV$urnf z8PV!-kDQBbymitpH#&})I_aLCrVWcW=3yb1y|S_P3<%p%hrM<`o5T-97=+myNgACR znVrPu?2BmGM(c}c=*)p1wgW~iXXpro0)~0d7|tEOh>_|S1;}RqT*YBGPDZ2D26m(n z)(xoz(Uqgc?$EP|&bI@7dBmYJBvfu%=Kr-%ok;V%;MOkB|D578vmn78(aF zGz^)PjV(6O#`=cOToPi0rtyOS;KG03e}^;A-8XO=i)`&lT}awcXKB(Bu0FQBt4EiP zuH>$s3ECPLlHN0Cg{|oeUH38yR}@PVp^WlCM)}IcwX|SHb0}k1AY<39%r}aI866A8 zu&wZF|5E=-=js5C1SmtxrP&s=ck|0vF1?fAc)53>EnHY}{e^2U_-ppRp$rzb`AvE6 znG3_Y)vK+6+`3Tiwm|N-wS8|C1#{b%^!IY}7WIHpSh3i&oVv90EBzm)as}HqY@8)y zAz4@im727>`jjgvms9-N2j572L*>tGU)OhFFaEuxf8Ed^`V$}K3U_ULTL$;0`H&Vr zKguw)t5rX;XSW?t{b;v>{0B5}=aT9%V(V$0A3+xBQAd6E^Vpg>#E}*OGzb*Ds*D>_ z4{4lAnyo`xr-~%vcvr{0YvSIuaql|zu9NcXV!_2aD;lcG|lc%&K@ z3F?S}QOwgkab)x}?u(#ez-2O!&pk5u1-ZMH*{G;*Fh*|dP zbx(RW1yCb4O<_9w&~u-Tu;1Y1Q`o8GF*tb5nA)LBz>46 zYJ6lk7C1c2Uqm^K&KH(^tx!DwJc6kT_jPc7mkZ|I$pNIJ-#78t|LH>d%cxGy9f}f-uUUH_$nI`8satQf6PmYhA8FJ>y z`5ZZ$>&p|N%N#1(M{Kq)abdmjrOt3#`m%bdDyYs2r`i^0mU4pX+;Do+@ zNM2N5MyxHIVqF})JP}mqgl*Z&?Mp|4YJ1p1c}!`9aI;DGjo=YFUs6{Prug zLR0ul5dDQhzZ0f64RI9$=($hFy3Iqp4747P)qhA!^faxHY;9BW*axIAwr3$-k}Qnv zTL^=G%8)Udm+f5$Gs(i({)Mn)Ss2^H5SAi?$M!LVnPutNUWPD>ER5}EP7|Lxl!n@} z-3uwKGDvJ6LzoR=>Cu|89Sva_GDvJ!Ls+IPFWcD=mLcFYufmKvvg}&wr|AYL-HZC=^d&Pv!4#sDzRQ!CqYuU%UO2Mox{Q3q;p_sG zUJB?mCuJpLzV-Nv^^Uqmn3#ls#1r$g|27j-oYdmPG-9}#qEkXX zF~ub+tV)UI?w6_A7n6dYM`|~%buAyB#x+=V*t`KH3p(OEaCSogon|;V(Fm|9}o9d8WI*FLTB6%1i$kK>ht0r>In6`NQ^8TPYTShX*h&fy4JL*iKlL+FbPLfXS12M@X3iX{IpN8c9;!MQ&JTyP1cy_FWu7+@a)|0No*c5*WoB(hT zA*nn&KI-w}^e@RUNRK08U`i9<2F{#|7)BT!I*KzT|1wp{Brp-8>wqOTt)qpQVT?_$rR&iWZ~-5x#akE;RTI9u}i_)CBkf5x4su_{&i ztiu-TOa08b7hBt7u^Ge!z=7W$V;0;qrYzbn<3OJ-V=Ckptk|w0tn6NP4xOlpflyFF zr29-zT@udC$BAKdv6*-W`Qb{O<#U$XSFG2%aJaaamNS2Np^b8sR1iHsA{1G1ue6c? z_yh()<>r%n{CS*kylEKHPDs!F_u#}63LuxaI*%u@RZ%DKkdaP)_GY95!sKUfk?g0l zcSrQCZiC~QKte@ruxL#A-ELt?r9_F_c?>a$+8J$Oj1XgNEf5hRVZo-5>|K~XI$j4- zKp)AHXzI49Nk|MOrONRRlf%+33Rd_OlrGPJB`k&cBB{J*0-HIu zFHxi^40L@$G_oc8CA5!c2u*~+|BYhFp<4MI@BoA9;B%CQZ6zzC0b3O@O|tWsr$}Q( zT^G*GUG5KL)<{eW%DV}%62(k#qm|wTS~-?ppp~)0 zMe~caviL60%CYo5EELex8L zIBKaK>5=RQlw74rQVM)AS}|fcFDSi&IO!9Xxo3doh*d7)0hI<>vNuiy!x#rNbeJ4Q zu8j#&$o)J9PL%8-x+is02%|Jpqx4dAR8p$+xS%KnXqaVO4XFpb|B1pVVM6NZvCf%{Qkn;tgjCOuD1sd79p5rMBGtpFjTn6rBQ#=!OpFY9 zxV%8haW6)s#H0x^pG2B~%2^y;GJw#CCmyJ(oBBhA0DA8KM>z4*@lz5HB0<5XDe@A3 zP~6x>;(-W?6?WI zqh~Y!c+(Eegh83m4=lL`wl)(6{btN%5-LUEyrZrawm_;!;-Qg<0re?|hA7h!hXxIU z_@N<}=?HYCo=-vdCMD?JDD~IDy0@0l7$-o8^`|*uf3iGr{mDSY`i}MN!#glQC6hxH z@pZ$44vaDPJ|)XPQLhm2#JxfS{P+nQV+(?Zp&il<>Dg3^>WMftjG?EEq>Iudr^n5Y zoEz{w2R+}J@jf&nqJf{k)E7zfTpXF09reVnOpFTiBk`)qko6L(#Q&6>7-kcV`yeGD zhf4Hizyq`yQ(o$3a*m8;Jq}FC5(z;G6Qh7c#9O04#=;S3vly+$hM|mgBM=g?c!7}6 zfdmmq2suww&v>F15p|?z)dk&!aVOfsgkgPx_>KA}lEjpz$H6uQXTc3s2XW_Q3{r#m zhJDoF8=oAXaP!dB7Vw;y^o`+}4b&}3-w;aY$)Rfb_P_%a3u5b-kUJ&}HxmaG!s%?8 zm|8e5r@y047ZwPDmbf1dBIdzli$@Gv}yiUU>>@rR9O zzr`Lf7QLe`5>P=I5~HFWF%O~wO&2t`h)*e^Lnoh`L!>DcLx&DWgpBxED%`pnCpBAs z9p%s*W$Kck+s=C~T>uNt7t!%d^$*v(1hy4p33>#C^*J6jc^R@8PPQCb_fRrV4%Nd~ z2#+{@Bi;=!ejh} zDi`NflE$I%BvKoPJrC&1x#Xz;DgJS)cB>x)lKQs>N%LESr2VZy(mgJudd{hTLg7_8 zlbnVD>Y=zNresgMs?$iWSN*7d!lk3%RzI13YmkziDK4dG1IVjH%gtyx1Y%pXT+-f_ zwOsqimOpkMx}@u4Zp=rQ!pc3V^PyHgCA7u2aop4eQ3srK*?Tu%eb9;e*iK8!*KsA0 zQsm)sgw%V9uA4kgsUY$!XmgU}yUfv2Lda+a)Mv`P@%4d7DCMXly z>yI~QaT!F?MVA@^`%T-UFPks~%EGIY>Do&zlVQXLyz0=>tY}LOUY#=~;V3tVElZ>W zFP2}5zOm!KS9ezCbELXIkJYjxs@*>6ag$Q_+1UxZxNZxVOCWrR-Yk=ycU;^~7swn)_C)mF7xd5>BLJy!TMS8Y z(-Ug(_NlY>ZOxQ0dUC=Q7ZeE%`tu%p<(AndvZQRy$8p%36UJo=U?O-ZQ z0v5qw_s{4uSq%yqIbUNxGj*}XPKx>n@bJPlGX%_x)KNjW)axB}PfVeO0*}CFM}Yl; zXMF73j1LLG2%DVp+B>{s5Ja5A-P5UQJKG`bmAIW&V|U{ZWsT#%1T#u7*oiwKz{8za z)Qw$#^FjUJK6(jpxXwEtRgS%Ie(Z(G zi5H+D`@+NvQ*$qjOdx`8D?^FVJ?5LsA38&rd_h!~bs$#@WhIbj<}!xP5N-(6)LFR5 z%);}BDhFF@Po4fuV@=bGwFk)GP`jI6x4$^#m`fQNj0V-7t{yt=NR6loOhkpEaI4K5 zQ4@+IDxf%`CKN|hKrvGvf$uyt{m;0^BHA-kGc&lpt-|%ah-$>`jc7;Q{0wd*sX-@P zis(eGT7A^xn^U1V5ycC0DhDc$20R)aFHD>vjLhiH^-> zXFRwmr8(olT#2a9fj<>dLFz{ivW`wcRy73?qG}Z6Gk{M_L^KzsW^ofj0}&y9w7zrf zS2c;YYbI%&0TJg=9$eH+gBkTsoVYIf{gL_0dmQ|a0>9y9@I z-Dl|zy~ce8^O35C+t3l+8PCiGR#jXRL97~aJi8VGDo#nQ@y(uz7`i%fO}V43)!D(9 z0A>7PI1w!_XS>mrbU2hKOTHN^*t7WW`$stQ+`nXT zrc7LaxM$3mZ^xZSQ_7VcU*6$Q>%5(I`}q1(gQ2I+2JmMb+Z4NP?a(dtTZi86eX}=U z9GGvvYfKihRj&@L+5U3iuTKA`(*a}8d~Z0# zw&=Thap~epaxk+lkW#mn5lGoN-x)1zv0{1K^2~R)y}s|}zJPJ}rm_ycQT)c}`oQtf zz;FP6#%E-)i&e`bE6qV;C6%O2)6RF}TBt2&x$XLqYe!a21@jsL)`qnm0qgGhqhe7t zE6SDj?<;?h{JrFWasPb#zi2sq#^2Sn1k?M!-M8}GU$|eMc&qKU;zx&XKk;Vw%M)Ke zb9MY{<1319PTcHUF!*K&fDdjo}gZ#^FhyJSfKlKKz-3#rw%95Rb-S92LE2f~S>0747#_y_DTfd`SOTB5h^29aM zJaRXQH|;yVav)^H>Bb&37W>u3zy8R~WgJud`XdvU+{_L{KQcB1SPnnoCuat%sUyR_>j zpgd!#5?#J`aV@wiB}-!vD-`?1J;MZ{luzW&i{*F0V&^&3?|H>HRDBu~N0&|ql4FoW zpGXPNZ9tJ?8bukg%lLF@7AFv;2iNyYIX%}`u~UVsr|P~Bz7G6+lG-IGQQ4s(fxB;5 zQ*J6r350B7up1p%l{_=JZidxG=eg(^A+sK46wjx_!5VlDx(rNGz`sQSX;@*vSQy66 zJ50JC_~nxj;n1+YW0F5fKn}w3(b1Hx48od_Mi;_FW(8n0H#GAHk?q^~@4JAs zA7DY)FmSdEx?_raQeVCB6>~_RAJFIDHCeB8U+!M63Ybda519%Arh=84Q1R{n{!F{? zYg9>w`zno1`zub9qMbjyVdBi`p%mPAt_Y?$<~!drrrk}+T~+y;I|C_4{Q4tM*P8G9 z(2B&rW-`4mv=$sPDBiRy4(Ziz7FEK}Gfshls43w>9RF?9F{bLk)Eh?R<4KQr8hiJD z2+oywTeVx^)!|O04)iG}jsh<%P22Rdva4FC12OOMUo-|75 zm`t2w{vr`oFJX#KE*RS`Bk1((D4lNd8eJwS1!TLj`K8I>Tqdh@CXo8h(0|UUeMq03 zWNoy>_n6GQhR~}CQv4gYNVipsfYoHD{vmCuU~MAVcibc44yPpaY=RUC$6@jytupL! zRyE)ph`fLh{|qZ|E0{}U$OAD(#N@>1;5fvX5Q8&n{DYWe#1MNf?mOdJ&U>n3@M; zL@;jPb+BHHPn=d9$^Ez&3W%9Vg_#@wASMej;19$Sd?c&oq%K@VTX%9b`7BQ8GIg&8OG1w!9 z$M&LO#SVqUZ!k*y2KXG+eN1T*$5vSJ@J+*v)nV8_iJq1>*=eBti}>XChu}nMGY;G( z%$2Fs0kKfoudGctKiIh#&E}ML$>+ImXzW}s<`Z#`#uP-~fQV5v^xagl+el1<3L(n6 z2A;;14@*1<4uRT8+Dj*%i_`r6NV!OK%uGQ^Q(%DC?VXqcMdRCFC#72l9de&m2ckp# z`2#;Ze{1ym`D^Dx1-rj{V$J!Tq4k2@KRo~I`_$MkRMjZ>R%G~}g9iREisp}y(*;M~ zaK48^kHQI)r_ylw_m&83K8C1M>)=- z&rP@|V5V*I65mFR`i~UZ4qrsg)Jl>h4P|ygjLyrGtc&<#l>D2Ne3+a>4Q6d+&5X6M zj}ph4`9*r&+}23axNizadWc6J(d^eLbF5)qc!hDJgA~dzOhYLlTBxbSPw-6(9l&~q z2D_-yEl9Emt-cgE_Dzp46|4!yKYtQ{{{sJgWWQ#f`{y)HQ}Q7W;MT>RL1XTGJ1(O^ zLpH3l1$22UIjb|TU%GiIShVZbiO{}Y{Drd$Ls^x9tjbVUZ6K?5Avv618p^K?I+8)s7gp+W2^y;OhOTi@j%85|fcKjtu3X?}Ta9T6xd&0WZfG+>t%5A}n z6W=zhs6%^>{&dgLt1~wZ3r2s&iT8{dcXRRs`2%mB59SX9a|RZAJ`N|PFWatXU&{_A z6|Yb0{C73Y`7jW8+|FrG+)tNwL3jrEV00=e6FT(ZLur6nL^!oU< z@u03^wKG)Hfxoc5B4pnku z2o>(RmG<$*K6=MdR799#CF|pjRt1F~QpD=n#zL_4<`#!?n(pK@ZFEw|FS`|7dhP0o zb=&rh9+reF*JywJQ7)Hx5-bosm-&psmxa0c!)9A=rus)FE!?;AZM}Kwx3+8H>rf zD+SnTQ^XC5?&^Cc>R>kpJX^tj=I{&|&;*YWMq);27ZM*GJTKw@{r`r!ENs6JIw>G@ zQiC-irjq@-)O#@X-C#V(HeRq3Txv4G<5V-Yf+EqeR3eqIQ3AHS7E$<##2!vb#N}iP zJ0O{Ku+^s*tCZ;Y7Zb;%!1A6;gPN&e^hgri4A62H_Y4rPGwvBo69Zz04ls@q95+P5 z*rk<(cRE>l)P-?RF(->pRG-|q40w%+#+)#zv|9<{M>?z|I-HO&&1n!BNeZqDN1shB zBt80}=)rQfM}xp}+MGTK2fdO9)4P(y_Q&V20|NaM>(Hg#3=u6LrZamIW7ee`lny_M zjyz7&ZWRvps)1?|PVCnuiOlRAQjkP2?%ArPEWLCXie-vHe6Xb;o*QE+Mc?2y(dRNG zI{3JBiQBzBIzNCR>1Y%yow4oy4y?QXjQ`)q|35C`YB+{n+B+r&U+|on7X9Zu<0GHIIICdY7Is+br$^6zu!x$?*~zT0 zos2;;rXz%9Fbq3dXKxieEq0zX6eEhd5B@uzi0awtvEx|r^vqP`@YMN;^1_)uhl=qV z1$vSQO-CO1!h+pP;dUo+9V2>L1BvWoG7ngLoQ3#CLvAzVL0BUpq&UYUBoTdk>+#m% z_O1az%knLjebSBlSulAL(ZOZpquxC}ML?04E`&wYxSIx$7obb!VLVZuR*Z=5SVv!b zS6?S2OrsDEjX>N8u@m%V&SK#{7tte>Ez^;lD1s%9XKWo8$WS7{Ag4KnAAXP=ntqIh z8cB}gjx`vvDH5iPBUWfOH7%kz8_`A4!pO+PVweI~ z`{v9mFMj#O<&!HjA#=^Txn@lf&dR%%kji8?vbm0{9m9fiXi(|o*f{?zzuP^wO zhO^Wn@h^$u3R??9)~bNDDs0WYYt9RqO9ST84|Qs@cD@r0(&)Z)`16Msc7CxxY%pDM zTy}h=I;2j=T`GF5x?Ht*B524Dsq_8n{CjE}JOOq7U43y_Z@yx$%^>{a}sk1>@=qZQDBc zDt^?g=*-amXuFF1yKJ2Ws<%vv&MfU)$ywwtQ*`EN-zry=zsiU-Z*5m}f{57BN-sZF zC^~nkf2_&BJT*h*B_d>0x`D#6%Us$Q=YDTzF!oh&4*YNCkDF*&+1+_CxI^ z+5$F`2NKXQ;*L9zFP0Yj72}{Z^YHPIP^kuEL*FFXTO}iqljeGE(&EyAorF`am)O+A zq=|c^F#vKmaf;*SJ;>t*o9ehZaSD=GK`fJ$$VRmU-^$=87(rqRrXqEMQf*ZZxam|! zJAUGxO)zkqcTl3!5~mk?7POOLQ{Jm+r6ti!IJw#^GqKtG9f7%qv=RjutCBMb7mN(M z6?R}uf)3mtw@7aoZI9?>qgY2fSgWvg{_rtjimeOB&01P&yN! zq+FWC_|0Lz32R)7s9La{op~Zgke(TS*rHUah9lr+0tLb@ zGFCu6*Qx6J6LwlFazBaf+|Y{C)FnEQk-1HLX+!)piH{5cOWK^Up?`F0>b%cBI~}vG zFHcO0kZ$_q!1UDgEZyMs*+mUK$yR2aq3xV!1VHE;9-=6xmSj8QXQ7xW2gfWz){sqq zuRVHKwDtmQde|$)e7L!}ug`V#r~}HSz2f&kU>b9Z?w_!a7)0B|6i)Evfs14jo!up1 zUnh{Mr;2KKKtpi%kHMkK2~m^VEON6Wjx#sy6H4q8iWs*J`Pdgd#4%gN zH7iTGn<~MMJd@i&N=8&XPhvtKLUGYY^@xhc^m`}VlV?WV`yYp-c<%pby8WF0iGjbF`Omv=R|TqF?2ZfJAIiyd2evGug}P1F zAA+uuooPtoixm?X6kt)z{n9HB>L`-?5isDEf9InA|E|YiN3;DL1L=+*l%2l<61_8VhkMDx|q56QYf-W{Y)!9 z)q_2_LJ)=SRUIel7yL!S|8&R6HiE+FOq|MG8d4T76@;WJhkCB^@HkciHqC%K8FJaA zmwkDJe+FQfqA0!t#Y~8^&*{LrI0=|yv9QoZ7Q6|cQ2gR2`C3#Adb6IhUb-(XEmw5! zn`Ynxft1sy2TVaYH9s%NrOe0)D+3aM$hIweIW`pI^1KL)_|5?I$Mf91%v`3gnG(#b zUoeF;a;~0RI=B2{Fr#+C2$e}w*78w*&4FOvfv~lF<%NK?*>7s5@XWjU+t&63^A9Za z{j8{YDS1(M&zkd7YfU(}@M}FE>bT+&*>d4Y{>5WL|4Ox={}}Y{sDm%Zu=5N!)^e6K=iU;QuA5z5$VA`h2qHN z$vzJM5;+41B`G0Rb(-xwv8n~VJuPYib4_d|fF*a|Fu#v7U~l92{nU**L9q{9CNQ0Z zKL2ZqBw9D3_TiGhAZgt}f#VdYVIT97U4Z@~MG>FXktzsW-=YAv_Are=sv?X%O~L#< zv*^n|Nnp$gD(1_kiO!MzG5}UuW!Du zQj%(J5=3w&?e^b}(Jiq11b@d6z zEpx&C%Dk782lpS6^A;5MjHWC5uk6Q%K}g2vFKP~&o5Q(vtIxgu*_)pYUYfr%QHc9>4N$_v-Mq*?OsLo zjf<~dTuTX696S+GxX+G zj?&K=;ip6Cp|^JBS^7yIqqZfTQxf7W^no$B8Zg$ZfqrPiAB+}-N_PZG zcPz9o76wd3VyUK(xh!BVyIWbiI=Z$uPTOtfoJ! z8r;> zDFe*qkDgOvI{w;6%6wmJs~IR#{HQ@OP@w)%!aeI-ge?s2L` zh%Wtv#P8iAt;AhITB(Iwq?Kxlw33v1i?q@uLZqesW6u_8rOS{=E6JF*NGr*3k(T!O zhryNPsz{5G^U$;s7Y}J^pJmS$b&$AdNSpSEw74oO@nG~5Qg3YmS>gx+vh5L&B`zUx zbR`NK3Z9r7s-AS>m?n}X$E4sW1DrhJL7vo}tnO^D5%VeWI0gSO#tS4x;0A@*viOn9 zV2Hz1ECRn`P=fS{N3u=!DJRnwIFm+?H#sJQKKa9w>!NHrzBI3CaJN{g7)O+GN@t=c zkbfGa0|FZUO&6 z?%~$(8HZGaEB;2|=WPK$PfUk4=5LWEUrh5DZOo@FM@%pNM(a?p1^far-DAKncnJK$ zE#TW-(prR87HyHLNUR0Y6mOBHSeB+_i!>!-n#X8O$wOLGx&{1F5kBf!wndsUS(@@K z(v*v79;2S+52*IDICgY2f-MN%1w zK4JSre=L>gd&oYa9(Y1b75jBbdqDhJZx>osi&ioIYhwQxd|Tj4l(SZ z5QtyJp24%CZ{VEhi~XW3aTpNw^xN#mUDRLB-Jm}tdN5WlVp$Z*%DTr~=nlgFP3FG%8QpX z{dg+OcU+WxnM&f=3!Ht%#XnDACg4ComQ6BSCRc&gnK@7FV*|cVEbI&NGKuJ1rVQV9 z*tro^eCNtHC$*myz9b8As<(Co@>nun1E~YbD;13DLMK%EKq32_A-(_$QE3z)(iQo< z?r9i5nZcd8k@0DKDVlw#UY2jJI3C8Z0J}x1SIJFPBn8T=^c~t^!N_mKz={JLlotUG z=1N%IqqcsLRje0A9X=l^$g1aZWDy9WZ{bRfZ|;iABgnx8h+Cn8|10W*H{e9l9yEi^ zzln%X^j%RE>N%@81w5k=S+Z{mGx4xA&Z8fa+&5RWS-aW}D=rXDle}H5?5d}GR;=2?0kSl#`nEwuqK)MnjNNLi^N1_@*ZJ5M2xD1Ex6s$1_a=R;(Xpx*H za+b+q0vKr`30K!vDB@*uXx<2)3)hU!PEPxTj|nr85-m(*sc6=Bj)_P3Cnzn5g6Ow$ zn#lJ&)dPy)<8`qP^YMR5^&Fu1SIE~%J|{UB==CBwe+DOFq>RyV#s7eU-=kDactzTM z_=qRzDag!{$8HlwtnmiQF_i`F?4)A@{cRmiXGc4`q!`gK?O<4n*ge~Ww!#Y{HE zL{Vzr%;XF{;e3X!hr$#me~=RW3013&e1s4|vc+(E0 z_JtAt6lG;iwnhiIYy$aM(<547kecx4gc-fCfMRMGq_O4mfp(6UZOKJlOv=U2QH9C* zKFPCuIzh$z|58`|o!K5T7q6R(SI%rGKc`44AYn_+?X+O-(NOx)g~RV#vtOxr`9R28 z8L(DlN90Y_`ldT60NTwuTc?6qfCZNY-NP*&YSS2&|kk{c~_LYPoqbK}&j zr~C~EgJrD?o#1qbOB^>^UTsSmRHdv~y^he9eIOcE{S*qrt zSjLJi4wqKl=zg_(^>c3|1xvdrP0<5YL;bS~uFfsZtyBdwYZs2ZXU_bYB^MvkyD|6b z+*)O@V$Y&(-BS9`S}wUVoMyXPwN&-4#pX{x8npC=Q}dVS{FW+6>8vTg;vlMnk~|ze z2T@$*Qsv84AyXb~?4XPs`}Le_IV(rkii6qP7rMiEuew&XayppPw9xZ@;jXYHXVnO4 zi5+m1IVlU>kRg`TtagWrcP^zYs_y2Megv^yTDY+M`rNnXHdOH6E2_GY|7!l~XV;7N zZ)gyJVi1)4YVzvjx_$44j)L@DS>27^S9|^2+SW_kHf?=MN}a+zp*W=o9XlO3 zcG~~6H*{<&aBS+eo!@O*z2x8B6{znHR`;xz9QDs$`gp@k87#Q9G@$rN^4}!;pF9&f zG7>m4^2&wRe5-jr2tQ2H>Tmb2m zI)iIfbSvIYelz*@xzNEAfrBSr+4)-2%B8ieKt)TiWY2o;UjJ$L#~YcHI7>v@`CCov z_5&N)6qqLlGEC-EPyzbmM(wM$Yv%RhgByhu1ZIKanBwipHz)naheL;-2^@arl`&jy zaIEzNstyLrTh|K?`OiN4@kSA)F6K&WZ|r+@pTDtnz2wkF35At%6%98gU!8*AxT!_Z zViN9GrdqFRm$ZwQLYATro4Ar)|FVm-BOaaG|-lI zRkf^LF}z!F087crp>S=#|JbS9FZip6!WE79`!GpUm;>8C*|?Gm=R*}&->twn&CS2= zxaRo%>X5C>Z!5cN%emUK)Z;Jicx%MpeL8sfX@3V~`j<91?lX!Gl9A3S4%1IjCy7ea zk0^g>;IbU6X~E203rFxV_)y`lKq0;$A1rJQWw$PL--C!brz&Kw4p*~fqB307?BCuI zs5%^~>IzhKE$V)1sR*a$`tuq?soTQU2WTx35_JcvdT!$iOkb$#i9pp86j8yz;o3@F zxNet!_t8LI?`?OW?pUbK8K`qoit6|Ct3vtBf&6ByqJ4x@8H(RGrCsT{+_PM{(y~(H zuWNmyLJ-FKTc6tCFkfrJHBF(K_CQU$zq(^XrO577;0wLUM-;!BRB;frn~Q_y;=fNn z3Ik}_^&d50A;aC( z-@2*)?x)zs{15JAcQ@g-tOipk^*c|l9`ToK7q%A}x-qE#b;FF>eDW(_6nVg3z+C*_ ze6-nrA?=w0#n%<5sjmd8k==Q*o+J`|Va8yl*xtPBm)Z+}4qYm$y?C zr<%2In={E@t~j+*`*y_+_;2ScPPJ%n7j#qRJ9TM;oHB^M+@}pH%E@10MR>49G1#CD z?#Y6G-KrRD(yrUoPWek{B;TnZ)9#Wb9XYgg}M6_ zsNByu!(grEXNIi7O3lxTwMhT7N)3h7QssWuL@9r^LxJLdwwt+onR~!6RHM0@lr>bL zxm!fJ?pA0hq=s_c-A1|YHXDZbYwqrEEg0Udc`r}#bfNye{8kHIeo?C!ZqWatPDB1C zEztgp-5R9*#eNn1_Y?}${hnI;Os?*pLGeto32H3l&(u+y6!rYKh=lq>M&FTf^*uJOO)(MbQvk`3f9B(=ZKJEzn?0=Cb_-x0GQ?k?V{ z?K1o(sJCE6Fa}em82UI6=|~j!2p8aF5Myejj|0&j2M({e0{7k6SfcCLBD=_4^BAp8 z1W^^cz~t08wYwFyD5nLd6Is0tiLM^Wu$chvKn1|WJtBu$gq-NIj!RPKNO;j)VoIXz zx`fIBlF9@G6}nAoG)9+kpgyijcol1v=u){D2Z4K%m@{_KJkf=MO|y#>GUFbx?c#5u zIV!_!f*Jo9>i}%1$<~47L}mQ{avey1U>!);YS*A`->%gNR z+T(^0UgOq*WIYmls@D=z_lSvuVJp%i2q0mN-2&wg{cc>dV8WJvg&3rrRPQO~EXSDVNpt^| zzDX!@`UKV`ZqV4HmTkq$CU&x7ZJ(MZb!hiQ^qzyyV1RjZ*k_Htv#Ti3zh)NONr@Y5 zA{a#%IPsp*8awZKe%u2S3$VscoN<1Vc$31N2~sncZuJOOUtt_Q3Is~}EV-MCm*l^ED2p9l76kbu65%YOm%Ld2G+B)&mNuO4+@^3jch zzrZ`=S2_yuJrBveR~=n3V8+X03hrh~nhqzT6R-w;C6Q{3FUnYYM6@v`AU{hHKc;Hv z2RfeY>No*zAmiKp5hYO*5WVoBVHg$1Ee6~Y`6*=~`)`7F{B`oJkb}+#D^y_Rk;E6~ zz!~S2eGrn_(1Ol8E1H#Xhw=o;VZazz8o+{OT-GM0ufI+;A=V?qLBz-k7-k<$B5N1; zG_lBuT%tl%;2bAXZx?OdCDTWDMb^EK|3{R#M+Fw(-=l|^Mtd=*srk9O0H?QP>(_UF zr9WgU37BA%!1i?*Qz{Fjmj(6ZU_K>l|BKf6dCylnA26^R)V6GBI1Sl<(5LuQD_|Ha zY)QXrTrys3Bjdb%m-}9s3>7p5@Mmh0g=`NLY!8^W%R-t118uOSvc1@RiPJT;^ZyzFid9b2QL+G>}L9y9LcXQ!1uF^xBk!bDeeYZ#^*}hq3JWAK#>J^L+K=kFl(G*iIa?!{)=E35__dEDb6@Ce>TglJ zy~Iaq9#JZ!=NgW z-78ptN^mTcAKz#dq9}|-k|Y~X&<^RKfUW=uX%)(n zyNUA+(2^eah$RnX#zo*&j1yY}yz3HSP^=HTBlk4wlrR4Y<}0mSDP(1GW~F(5YCr+{TP0Hl^;bHRbj&>>{NkPm#zvYq1DY(ENWCpCDPF~HaYMHXDViUW0y7#Z5*&^t z81N)61vs=OhMuwoOki5=&Fr@)h9{LseVnjeDACrWWUZ##jd72ZAFM9qPmp2&t4aJL zu92io?ah)MYIKRrWdrWPcnC6lV|^ZM6XO%76=xllhZOB%>ew$dJ+lxaX~4!}oaxSt z-8$$~rn{`nA|-aQ!7ec#p&3{)GIy(7R#!Srefp(sc0kgx6VoCrfo`2k((AWFx7C?d zn~afa>s0g(MOWW`F~7^2NOM-ir866raB_Aly*ZCw^;|h9iLG%sCaU`WG==)+W9N4t7D#P5E^?s}7!i9p2E z;d5W4%HXq;?a*oA;<0GkKsbIf?HZiv=*#i2V@%3KXQ%3_30qq;?hO$G= zz8T5?9R>V7IsX7>PEF^NPl_!_{`&-rnUr-qi0;Xm4#D!)_~YjwaO^W}$JI1Pl+v!J3Dpa7WGE z)P&8L*c9cfBZG#NSwa>CNNM7=;wHwWsP6u*zSz(kC!7P1VlA1%9QB)&{%FVH<93!w zEDD8Eu1(d0nw?LcE2@T97yAM|DdNT6B=B}Sjg-wjY438{`}&XD$?(X-6Jl^xRKz8z zoHIFOL34xBOaDN7$AJCNQ=6+EKgRIoI0+rpY4-LGXPX4$XjgC7aeHG!0}Bx@;2UD& zJEACyD9>N`#NzUv_fB2#+Qk{he;L&IoW@RrYzz|$&&C0VPS8Lc!JD9g7$)Dlxi=c$KJdc>g2{w`hx`sl#LO%j`=lw%|5t>|%n^_= zjfgJ#>Ae_d2SzU;YdeQU5#KqxI77w^N>POHv9mve50>6NGs3#mOc0q8**!{9La`d& z?S=N?Pbq*Yay>!57}iK65i_eGDABnHPKY9i7!edczeql!JOu3joO}%9e?axtlBEO> zEP_7op1^mop>pbUDvmTMO!&aB7VEnkUpxLMstYllVA~Xh$tuIP2p?O^ zWr&FlH=;aHRo22`A@O5rq(xF67&N>AYy1~g7$<)ThcTzJIE^EmS3nA4xD~%}@yd&r zUtB&HDyRz-)CKkRAwBHKZ(lnX+R+u*5o4L`7!B$@;92F9U4u;Rd_T0G(n~|Ont-h) zXses=#d}89eBaN^c?+t0#*{0&uIvh%azdseTrgiLxl#FQ<&C;m>w?AGLdClR#k+#V zE$gN|lyJ|LJ(u@{v-7U!zLLAB`GqxWaeLTS9I`nAHpgn&>(w`_zf&8k+83zW7pyw) zj_u%m4VP2)VKQgQ|D}z~$a$q?*&EEM4W`%4_n{8mFvItFqwy-TDoRbUv z77Hw}3U(lDc~|!@?GIW@z(>i*yLx`<{QAk$>rX@d#~XZlYW?JNu=u$)un`VMFIM&4d-nqYY8UH2A7s$fnrjs-EX!- zaiqu?Hs<*a`QW}3Xf`S`()9PajO3*Gj*V?xQu^XVD5ExzQ413nQR5PNa|~u-d8lxA zpm6tscHxD9KJRX7&hp-OQY$ZO78DEH!ubWu{PO5Z(K6x}(-vD7n-(qx^;uBfNpk>m z!gs)k&sGykt^uFTn0n>Fm!j|5Z;<4+afTK9W6tX$-2X zx~jf9x~jVBtFQi_>+0bvhnKD8H+!H`{j%+%4RV!%>?-I^zx=t2pTq5bg4V2`*Fg5o zXkDE48!GOWZ4P7>2eQiosW!mZmYe2xE)HNhxsFx`v5!$1Vx0U0k!sA`SpAK?*5{1uDiS zWQB;3Nn$$EeVve4mq%Y>NKyPijyn>OmMuCy)<%M&LU6ByOHMf!xDzxBh`Jb`5W_NE zVm`nV(5%|v1_J+vXsu+Mf9cKyR5pw! zCzv21R)`!M@hyFdHXlw+j3cM{fOHJ3LIGa;?rEA7b&0FvjRYSexvPEZQOuu!=xYO_ zZ<<>RF4oIfj}zjq42MX3=c7)v3fT~QY{dQ;tpl2xO+Ezi3Lwm(mwOjs`pJyGJ*seU ziXp;?zPk0&VY~_5Mw}}~;~d(qSZP!ynp{R*;#&ebtzlLAmr=?zxwg3PJFuyd zBt&1AVVGuh>~a~U9^%r3uySqgG+^b%hp=*u;P%DK_f5tD{72NY$!$bCGKEW!;FeH> zF}mzXK|LqPsd*F2IXb^y2)F4J&H)RT8<-)Cj}s38fHv`|bPrP-1vQf#&np~a7v4vw z7*pbN4Q~xU>l1DAl*F>i=S04=VKmf)=-w<*J4GuTcF9v7!`cMYH!A&~trr4ynH@oW zvuKy@;re;;*H%BWd1a`z8`T272f~F~Xc6twJ)F;9-+PqJr+n(a@OvDTaxU7&@Fl~0 zl<^6d4NF$}5O|N_u^l-a5@||zhGVPT!7&Ol{$d*ASc!or`nT(aLrOHju?yio3GFJx zyOb@LF#duU`-m^HneCJ$M&i0-u14$ds7o9iqh}v>n%lXe0>dfol&y_{ou(v)OJK)kgV8i#k<_h4KWYM<1fV*Cxi<%z>%V!G}`F$GW3@I?5jwH|2Z^v!T5Ca>A8^(132*+M?s zz=mMoeVI8ke7oTbqzUISk*;(Pw^{n4q@o4LhH_hIH-UX~5y5Sd`)>^DOtkei9?N>Gz)Z8H*O(I&PQm!&cmJF4?S4S7;My5Tco7j{jP&JB*@hrlDNB%!c-1| zXb4jow&*L|Ua@AxUU;-O+?{@_vW zA(yDFh#MF`tu32~o zj&ip6Ht+U3b`w(TEML{Z;g`|rvvjg>`FwKz-NKd|^?d69*E--Y99$Usv7_xu{9;@n zBk!$@`apj1*9Pv#C`#MG^Ri_M@4p%;dB2WQ%@s#vGu%-ovGpiHJTpl9hX(BFc+jU& zwi)Q}HEkER!FncVPM?{b2*!sjf;8-^-a~a(hyM@024k~Q;0KW&k8K?okCj-<<9YP8 zMsk5q06IHtG?a^{iEf)D(9KW8iTn2+Uf9BStt$q#oM3H>m60Ikcy< zd(YOMU_$5K&h6OK>>KHJZtdB=Cuj*jaBkf-*x9{x$JU`e*x7+zNhr}J!-h6EqehZw zEy7vv0Xz<7M^t!3cNP^Ne;MVQp;^|)#AgTpHZ%6G6SL<#SJVof{yFUh?ZP2GrHD%@ zx)#ruY~f0_+|1%T4|AP|eMhD~!o&{X#`lHW3Jyc|sIbV>^{9f0*`Lf%9euu2+i6fb zVi_($&^RuN(NX0uEuvw;eD5aFd^VhvN0vU5=_GD6l4o|(4~Ne0_08Y3GGH-bL7Z&Hvikn*jBk@Y? z{_;~_N)U9gy<@aJZ{^b*T$;mgEDgEJxU@3Au{`9e<+~OK6$bN#LXxuW zztF!}${7n_UnBpF6aEJM2L>IXXzSQUheeT)!kdaYQ?cLVIN$f4AqiUZuBma5n=@_m zX}947H0^|>)zAIm=eYddMJ+ZF_>3z2ZYaMO|GoHkG7ADNt$>@prX=ZO z&e8}HfSV8yC+_Aq-ALq{hq&e;fBw#e!S}8C3r`1t&7;MjM~`?XxQi+?LyR_;&5#+V*@zF7^m$09o1dg2L+3w``>g zns=;K*EN3YrUlJ=)(qg1qvRGFpX|UdP*EdbTQBr1TS^0#+@;+>w_c#lL|Z0rb>Mex z{FUifrtelaeix_=xi1!8)4Y=Ka)Ljv=|&IVHpI0J`F6Yc-N!ln+KzL1Cwz~e^k<$z zO*f~1pitFhENYf*6_BH+BnzQ8nM-D<(LH=^Pry;ZJ6btME2Ul(fI5R`EQoWpO+v(gJMm;CHva^}F4G ztbTFgTF)yxU*732YP+f7JDgmH)92pLyH9fXb)4jI;@&gux1UB1+q03wO*xA_%b7JS zhqTYgB~y_-uXq4x{nFtV4lndv8e-FhA}+aTIl1zBJzv+0-<`VNKxw70F}lzL?bY|& z6=`Kmt#a1l6aEA{o%hCp0blmkn^QMOfSBORJhE&a#b(y!$%~U;JqhFipE;MDk6(Oz z$?8va@Mee4?D!~F5knIWVLyGVI-CtN6GJH3Sf zFu#_L^9U%K0nd{zlq_uKlgqf|G6sZ#Up?N!ljHUO2r;rw%w;m|8ac!JnJ8A zm)hpL0|`ldLNR_^Lh-fk(Bb>Rqi>ZT4Va4r=>KbDd}S|J+55J+4;#rAJ9a-!8TYdl zNjV=R>&>wbl3>FpJ3)ayS8%Z)keNm2Blyf}F0(q2Y9$pH1m+(|Nxi)H;@&`O8}y~) z_xzcq-F#sKSJ<$e)97=$0%_@2Gp=OtX_Z`BC8Y2_jE~dl@0&qo`HQvl;B0!4^9rFyVmgVFCn69$Y2gDOR0UeN4#U_{M*@}Lp~ zKA8cRTJdkX8izM2U&CqcT+M6k-R*Gv8BVAdX#T9Vs}PPi3({f!xoVpYw>vorWY%?R zar3t-hl@4-Hu6}uD2Gcm%PCz3INr7^hbuI1=XMsu5ipv_%qIIgF zwX^{i+H{MN+rpF+(RYPmLR~tV-)to8?$NpQ_%pbSF0kWW36-&In}w!IQI}iiGKb4* zpe<83gt1+Tt|V77*avDb2h>av@*>XDO*7%h)VZcUto`ZSoVe^?t-_e}442R$jY;4& z5!TlU`w3|4o$xxuGih2#Kc1fU%uL{XT5>PKcY4Q8OhPutHQ{{{LVv;{|Dl<@pn+&s zP&*V-QS7B9hnFz&yobm*OvVTqN5~k3fyLbfZb1#HOb)7{EIFtlHOWB@DM-eH@na8d zj6*TTPT5sWkIhcZR?N&otMF7%S^Ch(L_I5@D(m>9=b>?G^lZiG@rm}jhU#jE`e8=r z7{TpTbVCD{cluOCC#g`b*eL|g>?SGEkgEa(oj&6oo2b||eQI*-+{5_pQ9x&jF|jIr zhVrS$*}%zBHa8E(33WB&Fb0!xG|+o$0;;zF#wAqPjJKR7ciCW=P({sD;y~fV$Y`kg zMnaFhl{BgApz5HRcF;@{HWm`XN08B)6{g+agr6X&RY0}|7!G6fyp+-l(PN~d(}Iec zGpss<6LU{fc??o{X1tq0UC1S14X3Zchv$6v zy@ZT_IWv%uP9|CG_v7P|W6uu&F)1nQa?Zt^rDR`Ghd;6NeE&}j$pJHT8E4%qs|=L4 z1hUE?1Cpq}AEzZL4Q)&;bPXqLRo>SstbI5Xm50(jo7vT>{D!itQFFBvwp%)7SF`4p zzOxdJ*P4|uasDY%{-TOrA=YivCnr3shDMU1U_&FFaNKxk#E_{O(DcW6V(d)!#J(i4 z`(q%BStU~tEi-_DOt?s5fR5TuD5ZgIU+k)Zb~U9qcMJyFBDtYXOr3V3<%T_XSQkTd z{@6hKHA#B_8)&6GVhD8W*+9#33P}MOxemGA09W|jiOa|a+BC7;(p}`R;_OPiFvPmd zG-zU=H9~)3h7?k?VNgvDmo=Uw6x@33m4f@~N_AOXHdmS}y)xF75gKl9Vz>on?a(Y6 zax>?4(~v6;x6}+EZ9Rn{DWo^vA;^rgmVsb)c2*SvtB@>&UcRTMz0fHLjqB5Aj-Mb_ z>{)PT#j!Yp`e5h)G%zFq1WBkWBaD={j5>b>jG@1~h^F;09=srkK8^!)6m5_(UOnyqJU6|o!D_q<17`ZB>u!G(A7!P}V+MfSqhSz+cd9e{Trz}vs zC6HB&F`33$1C6r=jI%WgO9zd!5u+{SS`^P_cNZ&{l-;?S7Ybmz6{GCV*W8LNC4059 zyHInhrn3f)@8>JYEF^O-arO9%dBdu4ms$cFcZpB6@wiJrt;u@Q1;)N-OOxszk^k|WeS=~^dWT9;0k!VVL^k6rR~TzYZbQwitL zWTv6v@l@;5(4QCs-zbcfd$rP3)@82LxDrF7B#n@BgrywLBl3JqMF`Ax{0-$qASWly z?TZ{kclGpcFlLsp4I(xM*(ausPK*<}g9Afp&(>Yet=*kq6qnPkm7RT(*-+};P92s8 z%pvapSqI6WEh;ubJ~Z{xs2!fn236jpY_96vLE*xqpm!&nV7%^PvB{vpEMkZ3ysfhzOZ?*m`^X~(#v^61qabufp^!#nYQ}0TfvOe zo92)6COc=cFBL57im%lKl9DeMT`c-)$$Z>LYK6W82^%c|vo(-ha4$S!V64!^&{(0P zu|kKjB10N0J~ATOTe{+(&orYD&)T}$>Pi${LF)9k2uJBxomWwXUpi(*qI*Rhx75lEW9LG+M z0ejJM@gtU~1Vz@T9L1aqr|4-%HR_T^jg3LaFi(n+lM59`C-E*P{fd~`QOUw7NaDiJ zzShr~zxEkMwF23vMY2!Dp(w%=6iOqUAuFDiuYFn-W-N$fpjg6aMgyB^R;{HVj-}GO zqkU0oVUQ6NrGg;YF)W;y|W1ARC?xZnf(fUPy=}xlUOfFoCD>H7o z^-KnQ!!U9unb*pISE)S7j~w~J>4^0vhx>pLFnI&%Z(48uvNdagwD%TBdv~}k=wH8w zzD06fv~d}Am4w_(+FLfno-JW%?=A8)Fh5XuQr)R?->Wcvv{%HIc{5@NCgZV}LhV^6 z9TJfNja^YifO8d%b7)5{F^qh8@vQ&>z`80f_~?5&oNh6;bjMlxlvVQH-xkIYghrmD zFP9bP)@{v7`=fez+VFlc?w&1TuF%`lBc69eJkMa9kFdr|jQyEXFLq@~{UzQx@E zdGhR#Jh_b_N2s2%RHaA<)MX_4!Zb!%ZjXqerF%F8+PEb1Dam;bV~!?V!;_*7X&n*m(p_vb z@;Q+&L0Gc7D0+q>QH#(hC0oX^|HSHNqtf&1g+L94XFc+z3fE8fudRO4<=zyNkl&~l zh@;Lfrs_)UAn@=EuyJoX-Ce8Q#2N&+oSCIsGeqeuxkJT2O# zJCmVT?ZDUy3Nrp;8snJNL31EvfaJp=B@&-u7s5UB%gy{;#cmya1^ZN^;Nvlkge3#b z{Vkr%{#H*G&A_3du&uw{lg;d<1DpG|cygFsGtl9&KWe_<&JlZ<+&s;lBRB6^e>No2 z67Or~Y`N}SC&sbpOPsTL^4xi1{bHqu6Zh^s=NFMO~{wp+!1tYAN5HzhNyExCNr1}$;;x5W_71<@wmonMvgq778>Rf@jETt`p`KgVr%UXBjAUcN*Moq|5}?z`Lx{*@w;bNxHC~F8`iw1)LrVlDnq--T`V6HPnTrE z7rM%uV;(tEtdNf_mUWcoT9ugRRMF0uSQ99R{5n6HpA$RkMP0=?e|K&E#1#5s$B%@@ zzR2;dW?)ah+f&~En5UwDucvbLSoo+h;eXl@Ta%qY*o{QC{V{D6h| zqYcA+gN6Cy4Z~n;c~pH$=V63Cbwc+9^J8C z2#@B1q(oSRwT@RHvBG*Vo&{-`EKo00u6EPx2s6Z z)##tFn)}x;*}wdk?3UO<&<|u>fqsO)&EXIQ9E8Oy^Mazb8VxAm*xmv|s;_KZ8 z>W%Iie8XcrwV<)l7)Em|*${H$3}|aSb?yq9)wpSVlIa(Cx)hx|q><G;wIxF>$b9A9 zjqZB)CU@=L>Uvd`($&=*<7sj?iR}REqVt%QDP7$Ill_yPX1l`O?0jo2?QmOfdBoSH zCmLY|w6{V`_N1p3v}M;{?$*fllvrE8>Q~zN&e~GATd*RPS#7y`o5Su+p1ITzxYnh#``DI3RGrB{OhVkk?+GEooSH|_?`Imt2jtfa)QiND*%dqcB53{2+BfMMjBe9AD<2VK$C?-^5i#DE zNn=RL&+tz~e`(*2osmu$`V*XpHVr{OuP=V+V`ly}Gl8twXB>JHrfw(>rgrF?%uEWM zolZNZfL8WzigZKT=gT7UBv;8I;m zdgR1^^d){@8P7w7nT>K7TwvisJQc+gdwi*)9*Tn=bw1y|x;PPMdQXi`9UULt{BJ?i zYeLDTv-fkUt0O_74^G*R+r0}Y)?5(|UqivoB$H5Gbr73jMTa1s?f-g!AG&_1S`%Tg z)6FE17CMF+l84(SkDoY&|Jey|Fdb6bGdRLA2Ox}* z=`n8vgs*^lk%o8=bx^xRXHiB^y&MxXtqvBnc&0s2a6b)=?BQd`GeHA7F%ImHoF0Yb zHzn@f24k+M^UUmYg-~1Lb`pccxP@~%(@#!#PmP{y5uEM8xKNN_+!zJ`@5K0=qjwY{ zj3oO_hk8UBawHt;pmFOILWj=t1Z{Na-AgJv#{m*NGC_VLCuXNk1=HkwPM;c`^vo5w zPEMXaO-LR+h$N&r1At-lAa8gGoij#^D#dfwV*3e#)<#U0lzqZ8dK8(SbF8M(!m@or zd=He!F;+SpGICR+kYw3AP-{Wu$ivve{e@G7YX< z-hC+c%PQ|RDy~Hec=VibDqwPa&f?lV7#aiZqsNXRi)al1#wNVtxva+~ypzWSJPEvF zF%cX_=;>;)&n2;DM^B|!S4vHZpez=IZno1N{N6aBsg*$e?!Zi@zR>n+fBw6^0yKZHmhPM_+~~z9i6G66VNAE zpX{L+VVP*}W0cA_>Dg8?PQU;-_=z*Klcz|L-05?*!K`rBMi>N&Fqfkt7oBKS7)kpm zGOd(^?vffVnlQ43oZe|l?KBxrzzC*}Q5&uDKoL)1 zR(2YP`3Z0uE~+91#%^QL#PKDfF1%r3?}#HQ&rwmeIO2GW#)@Fls>s4vCFD;e82+dP z^+IlG(DwcY6(sh|>=+>O*^uG=IlT@o#sC3^;tLcYW_;r4nd1V|MW@iPX?TeMT>m^3 zBcjt#?h>X?jgN#O*C)q=hR{cyDn6LtnRsfIuQ?nvG@BIQ)01d6EzZ3$Aty`|EybmA zGH9A$1J~q}_!N;l21C}VaRID@HJEQv#@55!5GG;_Dd-x7lUgoALkYK0yt`1l6`|y{ z;)9?`JhFo`oq)Is8jqbhbxNEfvv0KbajL_F@Cy*yq4)owr-q}@LU86Z`>F>G;iigS zbby-U>K5lEG5(;QhQE=c=kS&HK25ovB;#uo{Im2@K1X!pSGU}!$)kr1_QKV8+h_() zoEj0{h=@HRmM$KXhsk5-M5l>4@huo(c>59XBArOp{;ORg3W+JyI6~Fw{lAghVhlzz zBPNC76Lt#0tcb~ZU(*(Kwh3Sb)@(l2!KFF?QJ!Sulk&Nw{Obk2q zso*RX%u>TyYM7;gvox$2V#|^~P{bxCe-NXKH+`Ts5Kf4J;e^-}*#%b*T{-mi5k9Nt zy3(gjzh}r=Qu+^+JC^)7!@8p9&9_w!UpF zy{}WGl>@Z_Cqf(-9A7HqH7SIMuQxtddZBb-uV0_VYqETrETB(lZC7`OQCr$>jQHAi z+}`7Jj{0^V^&LIyvz%Mj&f$%PVOB0R`Hh+H$0uEIUuxo$2`1xODVJRDkKY8y2&zXl zt4FN@;Qfa$9`>gc@+l53#ohUiFv%aoHLhSH}che9De4$cg)FmErq;= z-qZCF&e9Wh@s>@TWz+R>zoiukBqpuI#HO3(`%yX2s?c(!1;882))K!Ncp6oA%vIOZ zedabmVq}_t{E}*#??v8o3a@&uc>LLwe0D9DUF*-TpC^S1`Gx;OfnRRsMnb=&uZ4d3 zsWIuffeQn^^y)uK_HEkcui5Ua?)UWz$`H=@4?pQU^c33OS><7Bf+H#q-43g!>6Ug{ z^`Sx$|Aguh_%R=*D$?@Tg2vpdssn%;emD2D!e>~X#?~YF1hCVINvac-)eXpYP0_%L2aVo za{xQHgTpUx$L%NlnY+J}pz?j=@Vmykca4odHd^6VSWN0*(Kr^K z@tcc7ATZN>wJPT`iDT7Tj8g`s;* zJ$%#7J59hJnt*v-<8$rhUE^=L#$TG{nszSi^c78f01X{^fxJpSubInhzA?b%^(_qC z&B(gic%|{`<|~`8mGK!3Tt-8{mVMQD#kh2kFKpuq+isj(w)Nv2Zhp=6Oul9tSF_EZ zw|!wCV9DVv?VP1OP`oKn(H1DE<_lW6g4RHGDW6@dX6sEY-{Hc~*WtRCoB{AC{CvrU*Gl-(F8q9@UH8f=UTJx`<*qIJ z`KP{~z}u=gTh%+6CEvCRs8asSwveltb2R%iTky3=OUDN;!+xdk#kiOBFX*p5&e@y% z>CGSLV#-rLQp6OcE)3x#Sy0R8_i*_={`}sB!GApS{63{uZ1*>G04mkfFg+=(%WallFhk8N%a#qL9&VZxz zmDHD0f8Vy)k7K9Vok}#WoH8CtwX-+z*{xi5>y2)IcE`;mF1v@%?&q@m{n>+y>OfH~ zU)0GJb@D|+H~0Grciw&i72l;AqOVGqYFNCnuKYN+`*D8v47Yp6H~aMRZu&Njsvf7$ z$!_IIx}8)p0H`U|X>y%X;hPDxRj+DMd#~5Y_YUL7+5<;TqU%mcmv3}pG5(zb>U)9G z#z1i$U%Z(s-psmqJ(pJ>D6Qp7w{oRh1Cc`-gvP=4(oU$Kp=*v41vzP;5~?({j`zQ@M+$IkhVpYyrre0iT)jJuat z#pktfc`cZ7@`X)YVN;->EKpPxD6T=~uJ`*KTYM!Qcscp4m}%yxFUEa@=~VvTW;O@% zN?)pap~|0Ex7d9*GwuMrxNl+WJ>Z|FHBOd+E0u z{r0-|bBkU`dGRS4rmr91insW4JKoPNej)X`>-N)s^~{^kET#H#M=%=KWn(n1$X?v> zpNZh=ckM+l zKE)Tea>cEFdmC@>;_O|oH@?wwtHrl_nCl+)+YjPnK*^{m8N{n>b!@jf;KDfE;V>``(uCY$1D7IB46A12+bJ+q}M+IW(_Xg`sKlkgTCC}WlP_OS_V$$Hx&Cc#SixC34PuK#Vk0})~sXHn#jt}Ke#!KL$yam0H+;}{;E=EC zFmE5ZV;`Z|@P7zUN%YjWvpOCkb9k8#jEbzv2eGK{k3Y1Bvd|LFLH+gJ*SmcgO}w$0 zGd8mvwETQ#3X9DzWOsJeD!-|GCQI|J?sl@jZdX2I*SwzFttZzTWy)vrHE)#L$Z=bv ze5OcqJEpUSTyHigpK)m3Z0ss0*AMfR&y;I^SkRS4uD^;&XSe(THQj?{3GBATLAM{5 zD4(g){Mcb&j!m5z&$KJvH7lQ~)x4Y7l?2E8ptVW!enFQBj-S+~JkzB4YeOuViCxWj z^tbvFiu$*8ooSt2YQ@it%Fd1o&ClYyk+xd#pG)Da*8JzP&PH%qa@o^JE{!@|QatOC_;Z`NwYLzJbKW$=WQw-VL^_?B1>RTOgw$6@1+;d`y)~$}h z7~y#-tNvRZ6(FEi-0G+f-Rt1r1b36ZvlH<;RkqGfJMQU-k8qv#7~y##;*+}s;Tq#^ zb=K+xce_BROA)%O(O9a~x4Pm{PUNQx=~F(sN^z&>RV*K?a7W_6Mfp7bKOp{7zQT^3 zP{{cFYEXD8rvVhBuGKoU#NARkWPu3O5pxs~I9H#gki!FD@_RAs%K$-g>DQM-%P*P3 z*k+|z2hq|v$e#cv2zk5cOO#_|V1l%r7j?PAXZ~fVs$|imHja@Ml>nTg^w`=I2+ONl z$)sD9L$N?qc@_VY`0v2K1OLfPnnE|1*fRkzItV3@E@HN^A^@K}OKidl7()(~mjt1_ zzXJp6N`F9nMkWZ5AnwGBGZN#D1Y!S#F@-xRzJyrUPO(ym4BHi91c}EikfWW7pcT>s zPjJgqyTPW5=e-&y{{tF z`B;RE2OSaHByTMs9X`TfR)IV9j@CqY$M5S*7gE0b6rWJUB@l-xq290C^u8|PLh6#{ z`s{qFPuGU|gaOc|ni!ywkfC3P0wR}U>Yvc$*EI_idMLglD*k<72}eWX>C<_AEvK)& z-sso2@|sqkru80}XyBu6CMXy;Y^`Dc0Po?o5E=wJpjXhvxf=QrN#= zs_ZG#e80>F`|E0DPle`njg{;f%AP9C>zO*T7c0qhFtd!A70d+D8s(gAH5$|S8K{^5 zfFmR*)X0f=(J;ma$Fq20!r(Xzn{Y+pfht!4-l-)45uk!<#5mGD+>teIjgwkV)CHiH zP)g`#t7*qbkWLb`ixl1+BM78~g;v1BjtCd>6{(Xj3t|xD4y0ioiwz&RLM%^gM7;1( zW*n}gr4VqBVEHm)nCS;G)PuV84H3W=G;9s^y0c1T4?>|8m)Ers6^_IFol zGH08x(nvDo{T|BVy-CI`7(hUxqXf}UV+BylZ1Q4zman6}y?;thhN`}%%Xg61Ur`_* z83d!gw#%nR;u(c=JmdWiy8gGQ>(hx0>iWcuD2_y-{Aq%HDM?^=_!HXCZwpv+cxyFh zt-fycTbudB=JWkOHRRt#x9s!V>H~uS6+g}oo>;b)UTg6+V4>Q#Y~AMbcmwTyzHNu@ zv>*0oj=;R3T+XV#p7?fF9oK%C%Nz-`ZuRx?+rMwF0(V3T0QN=9aRK?DZ*K z)yCI!I@n)JO6l5UdaXE??3KVhQodHLhZ)`bNYbQ&ZIG_&eMY3jdf$`Sm|ic zlkVZsP6x$MdUqUD397e!0V9mTrS=%{ACG?%{uA(Tb{philEUNJ`ES$*FML{7q{k(B)Svj4tW6ZCec~H zmIff05}gfepNDG!Z7veDNG`0{I`9=h{exc1FF;Qw>?(Q*px3sRp2r-aswxbSdIsNJ zPqN1X*ciQY``TE;1L8ARNwos1Uk%P4@riUFQMz>U?-D+G`S%GQsNi%CN>oG}e2nms zd9Uz^htH50Qo65G`?6spYYP-jptwt-B-+-cC=)h{Vge}2wAFPf%7l%gNPSaALKeAC zgwGx^&(eL}v}D3YrIiGVk4cn7+qx8G!bVX{21S{%61lg)XP=mYbYF91Ck0_s5%z!- zMzq1l>Q07fb6diB#0)Z`4_cz~ljbIR>7aK|q$1tnlL4Q@(i71ZroOs|%Z80gF%uMz zh;*d;x)f!?Mp4WH#iJ4>(T3b-yNxh&-0?8&ZWGL0cLK~jw;5(WK$<7S(9#{$WZsag zK>UIrW+B2HlVXTA_!Pk>Npws1aOv=!l@H-OA%zxgG$w*(v3!hALGy8ul5`hnHiVb{ zc68#asaanJ+b5~yM95T7`^MVoYoRsh+t5l5Kf z+)1ks%isuz+LP3o)TbKy3&AEG0bxsZ$6Pbby=jIfXz_}gYzx4rr@bT^DTq!%FmzTp z2f>!~4#4v7BJXVg5S{j(3yJX#F@a>PtG|MbdykMo0)fGJ5k8uc^wMW9m?S9q?gqE3-*iVP1K(`1|`;|Vfo z`1ay`D!g7YX2_T&<17qELr{_gWTkb^=mk zKt5j}Ln>?^Ih|xMQAbw&Tgb_zLJyIRy#uNGqiUWiU8r*=Ga6ONGz}7}k%=?LBg>xg zQSbQ38PDXbBQ=_PBUB;9VX}7g;qbl(BWOI0qc0<#Gr;Nf&IIE_dxX~>B&#h&lQZg6}uZJj{Ii8tCgqup=JJ5P|gmi8Na{mEO; z4+0uDrR185FYo5cyP;0u{EmC(r1RVFnp5UA_peSF1NH)+ zHtU`t;khl(Z6RG*wfL>5%<;p@Ku$TI)6V6z2kgbXy^gcj1#%1d+*&TT7O{AHHD|95 z&Crfuhm3G7Ehv0rB`UYhl%mR~T;tLD2F)c3TByw=8PZOdtn2a23@eIOzExu?GP)QTGR zdudsGTIrp%(v=vvz}th{R&vKyvJyvbts*NIN`CHSRd2LmWoyOF@sC#$$uCKfntu7vR}QTtlglC>GKKt76{VH0WW17b zY15*GGz%>!7v3oNc*RP7HmNXa6&j1)`JfhU{NoQZ6_zaQRHE~c z2%71l$(P%IyZ)~l-)y|?@VT%TzSrm8=gZm8n-APEAD|6K1pWC8ecql;bv7#(mAmUS zSGo+azE+^z-Jp4`u(J-1*V8&JyW3Phs8H^1j{QMpXA>N66!n|n#Wg6&Y*ykC*QR$i z#N3H3bk@Y&DT~GPJ2f%%q(Kck6L4OIU9CsDjG^;o*w(O9S7TGJVtImbqjq9}7=1}B zpka)X!X>A_6YLsFn_Y5|58|De?#P!8p+$ls0o=t$qwA+328oCz(iZQbH1tSgqgJtg8Zxm& z+TuNwh5>1mMr0*qMG1}Ykzt{U+25dqGO@%|#d|3Ac%&i2qWKhQ$ixz9i}z3(CTw#g z&}1KFG9x{-@#qUaiSVK2MAQYJB>2emUic)#hvw5!*Qdz6Oe`_i;ysjm3({zaN&rrz zo&q14lNLvheLuf#H-Wu8lEFixYN9p@>C!`fDD;aHr_UMjsm(8C~KN>5e*#fzNs) zl6IXDDGhOYBr2i})Z*Y{7v0hwKBVtzqp^zEM6>{7mwedOZvi8dcEgb=nxfcL#6)n9 zqNoQ&nGr>dI2uh+CTtW%11PRH>S#9{b)wVSV5|v`G1?8sm}rU{j4a_1MJqQG!P_*W zTr%U!nz=zb!X_f@dLxbw{>c*l|0uB{rBwp zhx;UE7dxIFmQWcI6h*QF_(7x+%@C1T7h~ib#lJXC&*-%>_R*)|9x)yXF-3a4rzjp{ z3l$5TA12t6Ku^suf@)}$6ttJKWSFoE%OX40%a~LwPv0cw596O4p}5p?BDh|(jW3+O zr3FHC0Zz~}#`L1bE%3XZBzn@pV8+m4yqs{}1wPQ@6yqcrR1#4!Bx9n`D-=X(9)lO) zM@G*Q|3y%*&Z7JUg@|IL7!fv-gCej`;z!5FCH9H;FH}%w9|!Kq3koS_bF-TcYxPHS|sBPd|VoSi{n?PuTI~!Ws&8|^mPqi-NoUT-F36mZ|j|p z2SbE6KODQSF4(Vdi{l^D2KE_h499ZSJV9J#d$Q_^$$zqo%zM=rghX49+juf|`p zKA#NE08aS%)5?|;%Wq;0ZlC~0MSG``Z`+CArKg^sp|x}am*3#eX%Ia~rIfLh>n6v<)hsx4xL{E8OJIZCFlggl=%K zCE#~{JJ=2}alh638@=<*%fi9K2ni|i9O8bzK8M%j_%u0SuqEd&HT#n)eq-Q#*L?R+ z4H*vaU#vn~9!g zN};mg2Aj#6wvq!I*i81kONEQ7OOB;ku$jm+Phrbg$){)qii`q2ebb%vO)G`uE>bju zjnuRgKd`2@^O<#AX5DgXJ!|*H6rx12Wt)=Uybr(UQx?zhIn7*7^Kx2?@975==vYka0}V!w2mOL1d(I~_^qqXx?AAb>W>1smtDX6<-fB?pY1Q0n z>~DeNhc(JQn>9bIwZZ5WODqo z!MKoZjD~yh(!=t3{D1JVNrp3{hLDF^2)cHr430*~Pb5ZnInIpuwRY+vLkji?{te|N z>T)ZYE**)-L*swc72?2P%%@9CD>}cP7-;LF4`y z!x`dJcX;k5mQs8>&@_dQePn!u4ZR)sApsKGtAY5$NhLI|8HTgMG~+fB_z}~oz^7QO zol$&QAvzd)ie@0}0$m;GLW|I^ME6HnnZ<6np7~Q2qCiYnx`*@jZPY@T*Co}Q#HSf{ zh_R5C3?;F|@;PA~v9K;mF|2q;onSp6T!>#T+NFCqpS%Bg^C_Qtn4`8)3Bqk~XB>OW zVK@3a%>tt?vHwK(H>|xi&bp|;aJs|1gS9arjuXfF!jlZ+8H(oz()%+!@j!Yvx=aI^ z{aGFp=*C0XOf4s1wEk!nsX=bE54lKqL?1<;qdv;?#F>Epih_TxDz;F$5g6LEWQ4WAi;;Osqp?U!yhqoA$L6+eIMSxM)5LxN+}dTljWp+; zwJjwg%xehqh%ye-DuP{NzMT8TC(_-Wf*pZyNaq2`N3@Bt#k*XMMNp9YW+^^ZEkOs? zhP9O8&WNaiPSik#oUl+tm@14ZhsAO@kBBzu?i`csq777O!5VdmrH`PF5N@lJmYGqP ze7-~qo=mp|{UDU<`7 zpa(~`T&`4F6MJ%hiF(QvOAyWhID9~ppIVzo`Lx}+;!cVk+W1f>vN;q}AX&KsC z?i~4;czRkUe5T9FbbfS*e$E-uCf!}O;WMI3(v$(gp|nWs4|8APd0o$C%UCOd~)D(UV0+h;A4l+ zv!YwNi|>zd9wTQ1GGWEH5#rtDg5nn>N}_FDiZWrNC>lUfW)`w8MVYWs6!SpwH^rPu zchpBdeCDMmqHW!@WWq+JRRD@#6zNF!bt%e(jiOixieHu}i8kau2|g!8w{%~_9W6rG zVubx|DU4`?Pl-DprbE(u!MLMRD=3J{PpP{Q;mbfzW;O?(a`=>r^ric{H6Rl!zw2 zIvSlS;{sRDCAkHZ=TV6XJS6B`PaYISASK1?OeOms?*YsZ9olHg!3ktn5E`UcBk?mg zGdAlnde%q=^QA8`6DHh@aWo``N1rHxS<6_?EC^Ckc>f5iFz+A3m`e`JiiN<`TI~7W z8{|{CJuLiZ7la`raaP0lq|}qV6QC;L!+KAUa}vf}76aN8u+ap3*Dy%j3~)Hjs0mL6 z1B(@8L+6UthjopN`Dvst$63zEU{ysEj!!Rh$ynvvqFLp%gb^em8GD*B$O!<_OZ*7$ zzoBBkN45)OV{eHTF5XWNB&az#ac)N7!?X9o-Uef*)8H<=-AS@BmbJtZe~sw>KV*EL zj91Bcox)-D5h)P)MM_hG0F)#U!SpJY`M}afaEZfUi_}_qU5JzIiCZ|SE zjWDdl8Sje}We8z{Nd$}e)Tq}3vDJ`oFdm5Nqo>Biz^oow75*jV{YPZ{2Qp|P8Bu#o zs8g!5-=Q>qmyDOl=%B#c$RKP)0fzBqvJohv_xH&7eKNqGiIm+ef-`c8DS1# z3WmF=*AJ>=aoCUeY;5QZVA2Nr5hTdTYqL3R_Fbceu+J6&{FetiZ_MG0IZG{^u>yM$ zI{gRgRWh52RsjXjZ!SB({XK)_t|{Z1+E>4wGxhtl{rA>l6h2TXQU_J%hXS_zRRDW{ z*W(PCODX=`Cc@X-{^Id(k6kbP{m1=9&2Jl92w`vg(!|@wa)z(>z@$h&sPt)419553 zcP|+PaZSIy)}L9&$B`uGylz>W{Q#Wm?Mm#A#96@NZ{;(}xQsG?Mup#6$;VZK!_6D= z@LM_uSQGs27FB$E4AP_mAGVsytoEnYT;DEW>u{+9w|D!^JArXhWdeLyal-ueWn(^= z-{v$vp%lNn74?7Qx@$>$zHmv$+v_=dy+3`^jWoV_7uUSY=Q_x{JRE+_9xi>__k`DP znE|Z^pdqR%lNWSvnM*;dvUWkYY%UF*bdE+3l(_cb!D}rGpYg}#t*A`8ZOV6zgYO!* zzEd)I`vAZHG`Ih>&pYccIm73j0npxi33-9ib^%4}ZNTGLiWYho``<}#1@6_7D&T0l zvG>MyUvY0BzXCs;$;=>#rj*nNWZYLP%6gUH(gP=Q^5W!IPx9tmpE(yyer@Vv#&TQ% zfst+hUkZ#Y!@;Mwa_O!9^!A0gpCzZ0U~nCNzMQ)6s=gQZow)CrzGJ%i6yLX>>)X#a z9k|nUK*S;|=1VtorJMaZTUKL{ZNc{0ou+<&(Gi%}HMht4p^>+SMhJ?mpP-KxUH=T1*R>%OSqNe6M#i#j>wN}AX60+TxteZ&UXKhESs7o@z!fwEvJ0YF?L9eaF0{BckC?8lZZ$3HZ#+W%4g>kbe$Q2zJW0h&;=9Uv0m9SrRNVc^Vv zQu!d3dbbg1pda5&un}<4HVL?>>t@Z(d|yT{Z|u8c>|?M&+eF}^FzC@}1nALrDQ@@8 z&YN|jLSQipm@`vJQ%Bav$O{GKhIDe)W!aJW(L_Cl?RQQKX1~Iy)6#m z|3cH5aY(QDVUF_P7R?XsT}g2Ks8)GMrTI}^mkAC|nQ|yb!==ZPY457Wqh)hn8KVBU zT?zB9f|+V&#wtPhu8x^TW+vzlwyE!?*$y_U@7Bap_{JD|(xxW+7A45Nt72wM4B7Si zLyhWpEw)3o>UYa2{JXU=^rVr(uM$?qhg#SY`v&UjpG#O-T%yUc7col^E;$CuuW?I^ zgpxPf62q36xa?eaOH5uBSR}N}T7hnw#ztM6Lhfj0GML;ku2=zDX6WAoE#e^l3-E7Z z3%9trq@ECS)<-lYd=)_%EW}14D*sGV!VrkdcE3KG*JS%N*>WH*r9MNM zPg8b32P4>h4Mwp079e6X?3P~S{aJH&JO-2Q7#d7q#bDAMhry&7b{s9}*5POYJ%6oD z*=^9g)~O8i*+W=Tpb~%N9nBT zYZf|6Cmt9LYg~8-{VfRL@Onh7hek&@qE;C@w1#|oTVgS$qY1Z<{Nk5heb;tpIOUg*7{TH zBwQ7&DfTG$DWwPZlj&H`#}LagXJF9+fF}C>Bgu>}Zo`QkUFOech!63srK?!^e=57; zHJ7chy^_(Dptz;))WH5)LQ+?O`n6mY+4GfTFHpliSGl@7(YPbvtWr2$@l#ujQfAR`oT4RZ$Hn>3oVyJ)r7Wdt%X^2x>*sALEH*_Lza# z{y2{o`y8>@tIZepJ=XL`om(fi_oxp{EFp@K%*Ajra>A~CIzSz%1^XIGdVoh*LIERg z>rmIZ^a6GCbd3`|E&7tDCiXXv0r3njBe9AE-C?eHW;Zc2flNq4;H*bWl0q?#Ppn%h z$s)LoBAu4+Lhb`2hSbkpI+t0<*}hvuMGmONl~}E)Qi^pdqz_1tc-gQ?Vp;(CkPngU zvWT>>OKuWtY!tbU$g@eL<4U12t;uz2xIRtT$uoy{^3Zw!>tYsiiJdx>mUhTJiFhg> z(n95q2$=-fkbFF1mpf6+hjg|;dIk`Y6XSOOuxJIn*gos+O zimA7>pbk(rH~d|e3Y3a+&Gbam?sr(aCX{X5TxqWKFx9km!eqEI!(q~0SsutWqyIF^ zLEePJWx6wE;cmjRLeYKY42D8a7WP-O#CILl_jImo;ccQ9lu9KKZBF8YMqREPv1VL$ z)^4k!0>CLw@jN;199M43KGfh>sQ+Lzv0=Z~E-NjP?aFoKHLE zk(4{xok4HIl~1xR?vzz73tR{r@=J|$Z3@i@Jo)Z??7KEZkPKN#0t&u)vL{sXT?MW} zT(r{wF52Uxz6?|Z#c!GV8o2W&Tyft*j3_kR4ZMj&1!tvu>&=b({J|_ z!M{kho?_$xA>7$wy5ika>@Jo&Wy0D-iN_)J6lgl^6Z45$O(V%CcZP7M8g`dZ4U2Te z*0@gm6nA}f^@d@levH694`WU=sPGbw;*yNbBQWI2n(u6ZKVPlidqomvt zn*sKt(`PG%>7{o&t-Z>~Adpb+02za1umxTdI_E>f&~$jt`p~@H1GPbudL!OJ zGN|6!*26;^TN4VCAv@_SMLxZxRMfi@VS;g|r%#o; z13S%|yI(B`3i+2wdWel{1qbAgypyn|a znzklJr-E8izBGI8^u*t7L#)5M4FkTKu~U<$gX;0qlipg2ewd=Sk*%4GcA}vS#%!k- z5mfIQ>Vqp5y6&KL(oxLjARPwAhh=L(l{B^ z4osY5)A*o%y9XM<1iN}_(&K%TLcUHRyU6xUvK5h0KoK>^CTHiEp0S|D3FMfdhV`(&`Pvtn10le#%uUIewYdZ0yz01*=Foj4AB zSnqojidsZac{->%I}=o$n+fX9R%6itZJuPU30u!ePtS=@g?6E1fFqNhW79wzA?%T- z#7Khkgy>YC9vx>evw|wEZ&T_|Qy%{THoX4nv7m+sct0X{Y^dUNlT*h->bh8Usp0lk zBU(^-BB-T$f*#1T(90=w*P#07nPW5bbzFaUOU6gwZlur)3$r#x#5ILgtx%L>jx^JW zk1Jbpkt(epYvSM4)W54K2m0+#((13f{b^hH6N2sFOkF;0*NR3R10??BY(BYwODdLA8_>AaGanN$q%J0YY0KitZ@aFi{Qd!db|aKgUF-HWIk_a4&*%yy zrgDk-d}1+|SnMllx#7Ig<4^3onad^i%*R6U*X7cSrHlLhNyYPV0VsRIns6!Wy3(Io zJ8yU|Arhgm(=ew_OGZF8D;m4iZr`lXa8E))KH}UEXnV$5QdNI=`g~dvhUo$+a%#1~Egz`R@Br z1`-VkY_I0+ot(Y%X0hMi&!_iu8teJK`2+76l0v(VJ)CK)PrLP=K9SdFasUa|=W+VH zr8>X9_?n8-m+|@8_u=z0-H-q;KF9 zmoVkiPC*BRE<0dKe(v!Lk1r-&O}~=9)bCHL@|&tzzMngL;p_#?ymGzu3(8A% zi@K#9oUP`1_l*j!zK^r@-LB!RgZ|_le&f(xW5T~okQE^P=+QT@*_3*@On@&V4n5i7&2Fj{#-5oh@SxqBD5IF2)4bOr_(hJhL0!+RLsZxBcV^h6*Dfj|ag4Ir72eIR@n$z>@7Z%qekc2j?D!!kyPIhb zPLGJB-Vl0AFxZHDUV>guZMs_Lq*{`Eb|0cGI3 z4EKg@a?19Ac{@N2g#GeEnEA^5{NsV7Vu}Pq4eCa9kDSsQF!xfVoyK1wMN9EVi;2#= zP#V)~{Kzk1aC)|Ux6L?f++{GmTngW7$;Mr=rq@!6$X}ngE8g(BWlPeoQo|dWaR_=N z*S0&x^hQ-Y`5SDz6HISx+){>%w@Zw>t){n2w-?~zJF!M`4c&sW7shf!XY zqv9y}j*+t#&Sb~O!)6*7!$iYV>@?)_o*wxGS!0C3Np`VBnumyr=kR~;1B|;%P#Z#P z2(^JmkuYkbA~h;%1EdDwDr%Dm8q$t5*dda40ltcPgbpZlwN$EJ!*DY2g3R!R!43Sz z6GPnc;guv-HTzrmDj|ABN)jnrI#^poq@y7<5wa~wOZfbkBD|165mCp>qz(*6Eg$fl zIH8VwVQSj@C*!$fw~G2O8}e?3{kZM426m^C3*2QX4phYJ$fIE@8V;n_Uy?gbE!qAb zQYxZ$ARLI-D9Ja7;^-1Xgu-aG0NgpEj*}oQj<#}VG{=Tp%PvaGohiVbNOi4-(D66J zyK52$zO-dxWV;<&>C-)Fylf12((6dWYuq^+?i5)|xmx?B0e8w+f;(k+GVa5jGJP2y zaZCr*a|G^`<;&1lvz|=e=DH-29_DlSvLoVZ?;a6X3-jgra)88%>MTD3cgpjm59+m$ z$8*BDYhE1H=UgO(MS6-T~eydc*2Hju4xgh#O(A zj&4SOMuS8`yMz>ipG#NdRf|RJm&iqX6zyAI6;; zdYwJRzG~dB7UFI316%J;)#Ku=!q31=Y6fV@7p4y1~9Z~rsl1H4s{(N1;^SM#^)APdofjiYN zgBsOqb=UwtZb5DHHuN=m8bp|qr_tT$Zeq9-wQ=JBVH;5gPh)BOGsG#t+^+(6iWBLy z(XgXTo}@d;ovePx5xA3+-yL*uUj*(H`E1_OXT!KthK4)A7lAvintbHpaVJJKF~zl` zKHyQbWM%xz&dQPcAGDJie1KX65V z!*D0vB&Qc|9EChd-^Y!~gl$B|IV&a|6*_M@pfJ5;42RMYhX@QRCmM1BvZIzq`T=FS zg)+TGz8{kFzmxNiaE^cR3LP&CF(>H*iuOsh=!G$p_2IWlPk`zmqCUWA)i6OhU1c^h?#Nhud$BELSaH0>$=($U_yJp&h_IjPUd2a05CZ+u5U{YqToLQkNu$bLGHxSHhow9^7i^xX!%#L7Y{gmZ>TMk2t<}0pM zU&{~ljRabc$!W&}*5jenycY^D7Y3ZG<_6{fLbc5Ugj)Y=flzKaZEwK3R|TOgzikle z7TDH*OAyp8OUi}LAHvejDy3nY+^{W}y`9wS{42mu-+M6pM7n){d*D;(Hv*sjtGVD| zpr;T1SD{J|0bl)=fu}=DfTxs)0-jtC13blZ;K`u^PZJoyp6#&hX*9l;YTR9HdM&M# z{A-N6OQEWz2>#cz^LAGlUT^M<-4kbcb4?tA-dtzfQ(}6{5)c1dcH5px(_2Mb%5d?H z$+)M^^iJIN0$c>3dlzm+-kz9PIVKib78hl#jP_`r z!~Z=?+)~u1$KZe zd^$9&bqBbf6WBEfA0mcUtTex%tv-PSv1k7ZYeg&26^lEbt=J$bq#J7j#$)3t>SySta)U{eVL+DcNtkNObz zc+H#$kgl|P&2S)$ix~U9Y#{d~=Kck9@l7FQPUXOYFBsNVk|4ry-08VM1l^fz-Dncw zF&HoE^)hlF(R*}w!2>0Zu~OT665Lt!#PJf#eQd3_8=MQ}YPuK)coqh>g2b3da>d(Y zNc#$3aWbBS?8&veX~=Y*85@Uric#+=+Ls@JT@Y#P^b(E(Vs~Xsu1Cjw6qPX@NEbjw z_e<2CRk2kulVt~wR303Ds;PF>C#nYP8xGxf(ejC^gGaC3spFc2(fC6ea9u2{0v;(nUPu zr{|oEu9rRy|6)0tmxfr?4pG$(Vc)!d{i!iXaQZjG<2`+3*t@Y6)07`&G+Z#uG|lY$ zlQyXu7kgpu^?6Dq0h}T*y)l9U6C?4WmD_%ybQhD35l@JkN0meR9h6t)N_Y`*nV_yz zNvVKS0<0tBBa+Gx5O-V}n>eGsfCu*_rniz7kBf1z6e5kQt4RW;7GpD=OiIPRjKJTfCFrPA8fWAlx4blBeaH=xZ6(Y?CDrbROIJ!^F(2L3R0z+rK<0zHS zFRemzEL!{{BR=(QBm(#rV~?F4Cl#3EjDsMKdC-%*=NIEnqq$Bjn$C}SrNtyx)oMK~ znpwhN5s_%rmDZ3`2gjd@yoIYWbupG9YA>l1Pa3I-h=YW7>wf|UX;;)H{4HCSVk^O3 ztYWL0kuG%KHJdWyFZ9t~V&;Y3o2eO}%Y?B?N7+T|RKm@)j2CJ?TQhw;nAV7^%>1dX zw_|dWb3*A^(`BDqf3u=`cJ(XkUS2o%#8);3E84%_c`fekq}P(JcYHG?xPI5d9@vS0 zRPh{^J%wLSyRNq=>vr96RnNA}#|B;N6<3Gs>Ik|zA>&t6trWG$MJ>UiHJ7??Wf#p% z2D6(&4i{OppE*g|F z&H3w`|Cl!~$xR)>!Y$LWA!N0BEKK%?20Kv#xJLKZWs$E*|ZL|5N2Ip->IzMjo);Fs`^xMtdSjSf{s@3yd!;5 zUI=5oxAr}{<+Rrk@GA6AiCePSv|2ZGO?r5 zbfqI1-q$L&H14P|y^#%maTuAfk-w7ptC_!z`PX%tapQVyM>%4>Q)BNjB)qfEME*@i z_`hX{g?}=o0UxO}c67w=uk)TdwM0aOh|%ZpfA2Anvg!sXOn&1Fi#~Lscn~qwgOhY3 z(GNYANUQ?N)M;dkI!Fyv#B(4$qK^oJ=K@iX_TxIS!pnwDl_m02zE}u)q(-N!lPN1r zwmy@-2^e9`6H;I97)EqsYK7{<7`LfDrWBJoBg~I@Mx{wrl}* z=kiqa^TVjta*`-aTyIK`jl>beWhFM-K`sA(n^H!!zEgig?t?Nx)W3Q#MSNN-vnw#+ zN8Hmk{@6WD5Q1INbifXFrM)w|iX@%mPibtW?4CwQvn#DF*cH^{MWhLlsfa6$`JKho z7*0I~(FWAuX8x?Wy%D;F$I#E>?~QM!If=}DBuw1Kth8kk{WYQIz^*mf%WIA$`_|O& zL4X?j{}XfTAzJl){AV)q(meS|%K#sVoEV9iRuoWI$3~^o!~v_?xA#M!3RKAP6U2&I zZ|OXRiQMlTIR~R|#N!G|O!6KbM|@1@UMH-V>FB?|mV~iXEuiD;YgsZNc2(qAqK!Iqx7HQpG?>@F*1SNCUf;?XzM_G2q{;UMA((1 z?xQMCd4qgdk^v7U=O87m+ma(9N>xQp^qWc`?Lo#YTWNxPVD|?HEjiDVL*$cmnVe~I z{)n6x$>}0zhMdol^JQ}WjGR9w=R74fNzR|pB@@UrOOH|jea8{9nE!?D)5}Yn$w%+c zC{Ruusq|2m_Uc1uM6g{xIR>5{zqAz;Q3p{(jN~?4FhE;zMiw`>cJ|XY#atkp3!oxH zu@=hK!kJpd)h6R-Z9^E@Py5norEs;3pLMlJVTV2I`DZRZ6SNhnG*!@6cA=YaH1ntX zE^NJ(V7+8~ZZed&`kGO;5vL>BU(26LxTP{`-L$5CzLgnhbAbu&%dM)S?)i#uRDZ2n zF6jzdw?d_b4d$d`5-qpM`H9((SRUr`y76%zhUK%|3MHpr&Z(bD_)JQu&^48?03&bZ z)pRZ)Egv`+LN^1Q^Ft04^fSq!d?#JzXwms?e&&xk!GN{^M3aY?fSYo`?1a%bIv0Uv zNz75q#j?5hrnNkT%I&$>gOghCA*(~NmdMtUpC!g6TJEevE#2-m8j_NqkI=S#E5|vL zIg=2m=m=zQx$cpZ+yRsO?r|eY%dfI>@G3A*03cJ z7vF3(c9+M0v#lcy7jGsRyQ|{gw06>+>zT%G&@EXhxM_L zJ{cVc7qP!#3Ee^;8KYaym^j_S7`6;OMh4+&Gl8xyS(~#6{);*Yj?rU^KsWw$R7`=O zDvzlF7y{@mA}@PXQE-XTS`ZpJllx%EsWSYa*TsN?m2=T%KRvglhPaSoj1V{yiu*;B zUz-EHt5E)Vlm$OBfi{LAX%0&=EBvcm8rS<+?wDdrCq11vH zB8DK|t0-NlEC>55C?POuk;tV##+MMLW*!YQRU{lUFiIwFvT-NK5zYVsL%!ND#e}^{ zKqm~xI8TleM&VNtG6i2m|I0RYvVQ|_5nJLG(e zoNvRaDwK2;Xfy^$^g*h2ZIel`V;E8mLlgcHX)(n=HUYb{e8P^PQ5c=UETu4`w}&YO zolyBET{8S8s)(PW8=`X30#YsK^Av^3DR~eE-G3}A>fB+e67nt3{e5s2lOk$T`ZL@U zkROgysCo!ZsJthU5A=cnd5#E>89d1$nc5GmX$$G4;O$qebSxpda5RBn>-Gj9TokPv@6!4PNpsnviT zP?A@xs%WpAI>4 zt|nhe4&~%tExb|~auzGj)v|MSC^zqF!n(cxt=p8X9{gsC71t`+wF+R* zyRMGF;83W!<<<4CuD|pI6-~*mle6m^nnX6afe>#wjmvM9TE&F{l<^( z#v|Y(e;FG5+0L}?HO6md7`y9D-^?s0|GKWm?k3aq8WS$AH^sqSwCIhzgb1Lan-Kvd zLK_Az3wM%2LvMvw(2t@bb#L`dFiRvz>nO%N`E=>nw_4DaGHBa~V->OiQr zMAvU2R9l?up;~FNkQP}s5I@E1ho3Pa7!gxjsO#w?R9m7i33cjM+=u!QkPPm)HVbs0 z#0Yn;wB0l(_0$TUl78kb6Wx+5ew7}N`#f5T_dhX+sZKpWkO@kPP*Hov3EMMH)CZA* zs6FF^?HRXXdlJz^(<3{OuAuFgw4RXr63>ZRFHT4@#)iI;YwWYLhxpW`(b%~Y2|n5FzQysK!5ik0LXWmPU9SEOA??W;ts zq{gV!3WUMTX-YyNej#YAoV+{<%z~3HHL|tlroC9P*UI+VIjhpRUB=J8orTrO_PV)T zrD?m2UpTB@w%5;9E30dOE4v;+>4EO0O5#FvrLiks*GGEy;e@I4W`#mncq)J z2OnK1zbKS_=uTX0ddh7>Y;p>440~}Xy+}!~meZ?488DtzEoW4N1y9MWl{0HY`Q=J} zyPV%1%5o}M^>S8ysCA3dx)VQUJnV6h5^~|HU~cn5S_^D;hq8;6>_$1eF_a5aQfuVg zHF^p;Wmk)?6y3>8vd3TOh91MPDX`WBFtDkbx1yDb?$;D?eUMZdN=koz-NkjG^gJcK zTuv{ChM};kq09m$vs%uq4&|3B`K@w(YbdKw$*PsJYC{ccm4*)dVDkzF?P%+0A+6>b zNUb6zyI#(&59PX)+!i^vMSnJ@Sjkx>=d23lmMgh6a&CG2?ydo%a>G>kk)KvD&u+==NddQ% zu_xa2x}&oc7jLdN_9U9#+~9!!txRK&)$~?YXA>^Im1rc_O73LQQbs5*zRB~EV9F9Q zDhW9~hyQy&$6)_h>SJk6S)805(8RPGbC!rYGzQ| zI}j2eLgU>;>@jRge^$^EU@#GcG6HkO=R}M>ZjF70BV7$xF@tEqm==BLX2|t-<8>-h z>ISikgf*J7l;3Ktn+Kh|bRyOwFn1ZSpJ5i)6z(KO2(D@Da0s_FYb3!cFTbE!kw3xuCmevwOtYeRxA2<16iU*3xh#54d`*+c*DIP1n z1WoKU(kK_JYmmgAlok6Gh;MO>aKA#WyECxZ$%N9dmFpqPW5?TNi&MiL2kr<6V_xSZ zrYepBk~+eDn&^v5D?zIZZ7W!;^AK8;_B?fHc?A@Uqeh zh;S{&ZuIjuUampDG@@s3U{-(+`w zT{8MnvN(^U{}-#NmFnTx-Nya;`3TlDU@WDYw2AKXJciYH z6U)UzP9;25q<2x{P1wOh^)p`rM^gzHeG5Fv7?}#en~aky3_Ei5jzc&E#P#}^1@xpFT7vxtiz)bOMLYOX`#TmAWma) zV7wAbUpf5zWEHC_TJ`cR+M)3=zPZrOvw6~fc!i}M%_*hsChUZd)^n1uonj%NYj$6Odd)&{K zTaHDN^zSLPYGft-0eqA7sOJ0O6oN_UeKoLzx5Q-ON?AfY_7Ww3nH*XLO0>1Pq`vU= zsP(l7&&C+yKVGq}kbpSGUK67(F*onUjGxB*I8EaOJ`U2ChhH2wdE_ zQeEoJM|{o^eRT+pGDLEvB;epbj3WH}f+0k<<@52I$xw<{$;GPzYjy;ScZAZ*XU3I^ zRvEwawt%&b#miHY@@^Kanrl&-JLTriU_sYZ|M!YpE+=0~2&Lz~mtGsnEBb6N7)xEN z!H!|iP`eA|B+(#0xnjB7o$Re>xX`~ZXQj&TJnTGj-YHR)NmrxZNb%^^)m=@3a;2l>P|uODzyW^}MPX3fNxFQT9NNuR zHyIh@QhGG^xcU+lX&*Tql*FHs^JQ{q4^bkFPI`$P>M>F|IqbvwJcZ4Y^96GL0~|k% zLn?w7;cnin!g={CbvDU^@A97$k;YF<2E#{e12RjXG@B%83-Jr(?Fl?KB-;)L%!koM zbXFDie($=h=XRW#N7KfHE)=cZdwZzYn5!Rntep6 z+brW}-K>SF8>h3wL2EN(%eoyGpB8_i2Y97c<^`KGXf9UF74MoWW^)7P7HpJK(PCK9 zz(QT{g72~~m{qQ1Rm)k`!K~T~yFM^key{g;dIM?e=6mMXUwbC7?~t-@RNgll@H`%P zTw3rydD~z(XWSlh&tMogcE{k#Z`?tr9Wfp{rS6OQ5Q>B!i9zk<6$DJ#q1=49|CpRV zWeg=*pWpcWM%p?B&sdoNyqS&itd-d$j zK+XE^+Be+EG^7;V&Nak26jQ#8U$G}Zo_#+`D*SFmdnm6ru>XK;do*BvRD1cxzex|Q z?hZEX2sHKtdJZi-<_#Q~2tM{?;Lut2`n%bi?qP2_5OdpPj?4J+x!VSu7FxDmH_0tK ze?}K94vRh))chWO_|=erQfh-+^VR-7>3{cUS&hSyrDlZFcRD_ za`~~q$#YYdo92|~lP)F&vLHn;6xe$>aQIB%iHV=#qcC>H+)+;`&J)HiDo$5SFP%nW zMq?0VBxZu25V$jDH(l+FIY`gZ34w=V4%6u|oLHcjuAYkd1f6m#v%XTClp6c6k_A(@woIAY-Jc(0% z;p_|KB>c)|OO@*N_<>t65s6m=3ne_~mZB_Czpa z2QsRsjLKO?uG=uVm7&$rmapzWUge$ELbI z)6eFsLOH2$A*p<}MXA|{-@7#%!7Zq`R?Du{Q(dr>c4vptkeYWJ)^?&90UPHE=4|s% z1+vk5N93eq0n@R&kHnHt!(A(ep1bAv$nPAEVXS~&jGmv$^0XVjX!JCg{;;DJ-q+KO zo>ivTGq$wj;>{GJXSL}~Tc-^d*Il`A-!b$S;j~anK?|)_aNjF7dRk5Ixl+i#%1G{N z=C+bcvxatH%w@+tBFDa9IEWtWHb^mUqZC^PdQdVQj5`=lQ_R5xcPwF8Zj)p_9#a*! znCS+4OqVu44vai8F#;arqo4@-Ri>MBhz^qslQ>CFk@GY;pCIQgatKdTXRhbS_epZ- z8Fj8F@<>8OsSYLU$I{C1dxlU|9fqlC$4tqUg72E@)Yuepa@A7Yo(OMlhJY!-7#;pZ z12OePVEIe**`*Q4ODtUbehi@(dqZcp=e<1V!6O6}qODcVC5rVz*F`2&#u|6%p<8B#dHh1dXi>rr2(Mw( zi9Ya*gNNbU701w_Z<9zf9dF6#UV(Zx2{rg>n_q$_%+T@hHC@jDO0AWTmlJO)9A!`| z+WSYu71x!xCB>~LZZUByS(mA2VcohD+L8dVn?(FM(C`qRe_1{9O4eUYV|o|VqPX6* z6z_jR--dw3Oi?dL7PdpOXtYNPqIO6YecOo3Kd^{5L#W^@;t{?0mh{aMaa>+^^c5q9 z=!;7her3s`u>dJFMiejXMN4lKgeHric=XuK!nj-R57pk2__>lrLU}6*+CitE%Fo5l z45|(?_Zhgc{a4_|^e@6S_P@*AuQNBFx$z9pPV~Ca^(;i>IwN%VRe)*Bd-P;O7)%aJ z(fJYOegqTJcZ_`rP`9MP(u7~@s1idrk4=n2fg6W>{q+`0A4%pDdgee~6rDEaEO6Wj42IjJn;VLx^K-l48s}YXC zCIzK7tm~bQOe`XpYZ!n~AX7j14}=O4(^{AbuG8xyXq^>Af8F+|QtSCU84DchDIyOT zOk@z*XVE>R!{jhbj6q*It~E+G*m~k^^1VY2V}dJD!8s8wH$;&Z$nzfFz@QL0kZBK;jzX}zsAlbg zwenVJ<4e0<+(qHVp|aYS9)0mq^$Cca!Y;$7Pb%h|0K_>}3DjvvFvX>qT>&JSo_Epw zAu!*vP4~c?X7~Ut3y|Y$dLBqS02BUlg!K( z@KKg2g)MSn%df{rc`}A!Q)GJeZZ+{#W|7kh%#)v`*?r1OqV2Brq8fawU9*~8JBIRcu6J(L~d`034 z!ju_2v*L521-#qD>o1z42+QnxaS2y0-2-yL>`w=lLl5dr+Pm-)iUnqt)KNsxtfS1!(P7b6Y)0kdp=7ez!Rv5v@$5=CC0ZU zT;A}adv~*?1yPbwhbM{c>w_5F|F@zA65-+CE=*amhXQoK!bQ;kc>WBO0Zq66dx$3} z1)=TYMc0xP&SD`5;7C2h@p;SK#{u~Txdx@7q7$aQg;HB6WU@UJ~p$+rJdL)1_ ze!>e5HpZ@^@}LRqUe<#(4-f-X`uHZGH)!%La*G#DFzX{7)^-}flIt8fnJ8>!VKRhUpH3W(hhQT)Zs8{EP^lSMT*A| zi*M2m9f|l9T{e(2N)E#*7$%`h=S4veVZI;+JJL;Yrv~ zu{X)~rn%CfeJyEBNcl-DGzmeXFfmSvbE? zm29rMVRbCzR4X~Hat;(Uweia~Ij1dXZD$-u(8!QvzgTuVAvVztbR;dGdpZYOS%o}Ci2RV8K`ZkG{l-i|X!U5OAe7B&zt9y*u&Y?lOqJqlm0hiY^}B+uT_KxG zDQT1Ov$bC|X`sx_och`GN<)|2&=t(tI+gOh+`5ZBQ`;}?3E8ry>)*3gLQBLYlL~|6 z)&JCjEWoY8mi4yh*Iry3O3!{_)8$Q}42P0YDrc02GINy7ayhdcx~G)<7CFBql$EPw zRmxeFp_+zQHomeEqLC(oWERpY<{=oFr({>l+0~)kLM69R&TZ76%PCM5UWHb-DXX{R zHw_WbS~<5C;KjSSP1pD8&vbPwU60^5J*?`ZPzpD{TevxJ$QxR<=GBH*8!oj_AvNh# zT}TpAlikLhky4W#5v%=LbcK)nG^0M7zq`%&$Hv`_rmG#<@RIiVW@w-9YQx1_jmF(; zOm8)9$-~7v_FQu7w^(rc_WG{XI0c%Gn&<7D2_HMoC#?C8ll$UcvcN#~cyQ6?bY z=kR}zq^MQq3vI<6i&ZvF8SHp~3?-=sFBVC2@pTE52gHe(0X&gw;iVdv;<2_!z=~9` ze&;hfD9PS1SMEiu`>edQ`Wd01DWeaBf&z^`5GtZ66GiKJ=nahXBt&c%YFmr6g6QiD z)9y?gPyc7Zu|LYFi@3>G7oxMA3~1gRK|;9VyhMjHD&_9!?>;RUn) znKQIm0c+M2pD_l5ah<3KHF2IQnIEpTkjmtvUZ#x@ln62AfSh0q`eeO(Y{CyF{?lIR zBuEQvMfk>ibu5)Ifl*~#Om|Z!>Q?)ZdqjE?`t$n1k(7k3i=l8{iy6$`3rlSXX$-M6 z7E{N^#=WPYFs^qA5)}M$U0^XblI6|3#c4fPXb%N;6r_ zF<=?wO;@SylxsTy{lkK_Ml^%lD6$aFGl4QJJj>!S19>)a(>-+^BZKd zV=CcRo=eHAm-FgFr8P?FX1R28sJK!oZkLPO0sSgvTja7WAs2@5^|EXIouqgtWb8lV zzqIz#PY2EUcVZ0%jknVb*0c+KcZvy~gpuI%?Xc47LTR7P+R^~!zgucdSE}KAE!McD z&h(n8qY@Xd*BRkX=0*)dpl$2M$G`>#z2tat?I0xYIsD&?NsbzWm}v`!`Wbs-d?tnf zntEeF)*QT(!om%v@w1OItnyVgnaSQl_Y@q zii@F*Womi`=uUN;fj$6j#F!~qUFz!lMce7^Ku^5*RCH-nw*>~}czOc13tkE@Bo&dP zAckn8U3&^0c1d2=WzrDf0g%N?hRIa5Y%xM?SnFZB+8R+yWBzmU`qR%Vv>s+;I+krc zlAkV7<3zez>qQ&f)mzWvPEp@zu!%nj1}Iw8L>&lii3-AHgC^I^YdETRSlqU7jU}yw z8{PMmJzT0*o~H_+R?5vhNBzv7+HUj?8VrTB`;gXK+~e|D zdrExCZ2XDuEd?T(I6%}})WM(j5=&-^&}5_VIbP;nMo>RCPcpDu8xM;#@9X^~`w|4B z4m3@sa!r%n_)>gizE^;h@Degal<=8U7{*0<#HI#ZE*0+^-&^TR7uT&jomRdupZoFK zDucg|RsuqE(g)m8RftP*-kuCTqB?=361u%o>-#c2nFAg{+IAq1!Y2_*tL~`7V{>P; zVcv-_V~nqFa6gYWfL;=PxHAV2@i0%M#C=w?rilVrXU79l$>ueNxHS=RiK8OwfWhVP zxPiEt5phMO%hlr+8U{yrQsP?C<+o!DzHCqSz;Pa5KWk%;1G`c9i)bfdO%I&ZpXEMx z5igaSbmSQZPV+nZS<5E}W%cRd+;^Wrt744fF2p^^$NRE6vVidW zRo^iqHz8GQsMYo;hB0m)L(WB*|6pAprvK3mu|O7L{OhF z)vmT)bXkBA{1w9p8YaK%rhNb|^o|vM7uHP9Cr7=`k<;GMQx@F5I3pRiV#oNnm#h_O z8SU=c)-Zb7dwc|0P$B)SRPMgLyAd!ja&$~WkRPje=lJMpNG5qtpJ7>%GT*S2KUv&j zdleEfFFkCIlAQBDD1SkkWA>oSp zg~c6K=;y5XMMVWE9MIp2;UgzU&!|<3$Ip5tdU!IoS5{D3YawfOO2|wiqeVOhBgr7lJVY6lJ&dD~4 z%D7m8RY8Nkb9mHGiPG1gwM?zkj}~O;mzJtu@=zTVY-1@?E0gj^t>043wL5?OA-dLN z-U@Lf+Ax!7C{TA_B&uCMIpc6(U*ie#PmuFA=gGNEmy_^KX6Wft&s4j`WHE0< z_3H?K)BcI0Wa9`rIH1EKmRa&xOrRJ;Uf2p_p7F7&PSqHizUB34%8_UQ=@R+0FDODD zK9laUO|32}SqC{x7bOvmDxi}PLs2b zF8_oc`vUoXpPXal94DufZgi2em7Ejg{Cm3b2jo0Y&Px=|Cwbu|rUgi;fN*`$eB%xMHLehri~+sVr}#JRAO z_kB~t4@@LCtVzRvh8a64lFdcf%=%Kcl2aw;R0XZo;j0=srzU8vjojHH11PZ3FcaN-lhVLYJR2Km*)FGa2h5C#*!FwR z{LV99!nRDKoYNRgXyh!%TO(MGp~l>%BxK_^yXR@ zfNg`41bOF;{DIy21K16;W~f>g_+5{Fmy%1N%`g&}o~{UQQYF>NNp*-#b+}Vb=?j?q zAg*o3=3FIyfrQH2F(rvRjX$sse$SeX)?VLfg#A1GE{&6})|m>$wMKTWS;$@+@H{fr zeWPIQyg69VG1dPgTS2I09fZ-}ZP^xd9fUjEb-h&S8GNs2@TC?=s9&-LTnF#P8&V6w zyQ>s7%7u+{>*T_Xmy&LR<@)U9voAbz`I(uMO7$@aETNmHn*HRP;NoU3Ffl$I7>t-DeeDk!_1WJqnl zV~9^p`ziD|l-xwJy_b6fuI9h#oVNvAx&zHS0y{LwDXY)AlJ$p< zsh*$38d7SpRSYc{mVDWguUK5N#Wm9$v;dlyEj5azNwzcvEzK7?LuolmTBV!@g%_)5 zu~ofh&Ko%TxN`I?e)9=XF)NdzS&!W63G91pp>^oC!SD>!Mc~xRv_*8sFl`Y%F?|%a zBc`8D_FXX-x_=UHi0d%kuvdoc*`bCtO2a1nE*)30%4JefQS)wA&9%KyP~KZ&=qb;MlQek&zU zNhz08%0sC+N@}T`T1tgWgRDUT-eJZP$gBxu)S?5}DsCI%ZSfbjqYGH+!=t|6Bd7ER z%(M@ZUwU=Zl}*9C+6&tkOpZ`V!%Lf9+(dq7ls;Y>`>?Z&>Z*{_p;DtsPHzgPH*2bR zb?bXdtggvG%@e z?foSzCF1TcewKp1^WEf{Uw)930i_Dm)u6Y+deIulZkgLN*BQucT`;$W-(!!QG7vBi z{PK1jMf}Ab7`!55MGEGDXSXyS*kpWtmGP1FCRiaa#Kl{w#z!`p-b&ljj*EAaJF@pD z7`|1Sxj)AAtqt*Tza6_}ErR~O-H7{tzb+I0f3TDPA5xjWJd6ArvJNB~zLTE1e{Jk{ zO3mc2-Clwlip_XngGsU1k$)3C7K({O5}^dk{+8HKs$+jcY^W-p!W-f!q$QU8Yb^)j z6K)t|kDxKzr6&{vCM5{H~|I@Xe{G88~B6Xc~FC-csWJpjhK2~EvAUM zl0VJQq8ilm62cJbCYPSe$E$4+N_6o2@2hRvhhtq)<1!rD7t%$GXomVh6hs^ zJV@(Hb^snEXfAR?iWZSkhdaZQv9u+079Mw|J4@})V5!gO$T(&k?BYB;MFvRjsv{a?R);4V;~`FG5zbTZ-0EY95_qvst8khM>}VCDzgNAA7&5VF z1|U9xrSD{U7^_mvt}a2Sm?@Aj#LAGg$p%D@V7+3P>Jie0NpA zTO)$>h)8e=ZWO2DMm98Fn8D~MiaoxVF?Lka1&sZWHmA@;V%TCJFl>MfA0wIa6O-Ay z&-YDGyAa4Z>H~0l3ZNU*dP-D{o9_egPK_Rsywdrl(d(T96TWum8KA4-B%I-7CyTWA z&|d@f83qE|oqNGyW*#|r6bQz#vB_c`LDOnyB%%|yJFChgOvveI7>z0s7#PF5-k>lB zb$tfIwM5)Tk`9hBT#P|tB3O%|S5dH*2+YzUEFIQjU>5C$EGF$ZO=96&C234jQ7i^; zHRE1YW;7C|gHY^cbks>lqICR;#wQW67Rbd0@8m8{pv#;FfjiZsU3XHow-@Z)&M<2oJ!v}Aecii1^z-29WpcCqZ?psIuf zf~pc2RAqkvR0Y=bhs0wLFhotWo+agy(_@OQUbfYTuz1~haVIU> zsnTYL7OWd+LEC$=H;~&rw=2-x6>w~Q&$2n7bkl)07+h`yIdE_EWm<3d$SQM`fSTo z5||Ag8*Uc4W;O+jns1g?%%%iO*WS!2m_B~>^p(@WoZ6711YC-CC?1GUwA|eqgMM>2 z3+?sM-HnDk4_4)yjRtErt+LPQt8D1coIM)IT(w|s4mZG)a?06&`Rp%msw?s%dfYX0 zPs|~1`?bAOR@vO6#$WTu!v4X)!9&6Qj|CnX3bY)SQ@jDQmo3td7=PtIgKy~Bj*LE- z63#OAwwPYa-m(T4ZzLOg*P7l)>1f5pTL~SO-c5$LQ!{$oO>bAlliSc`LeRIHV{rf5 zYmMZ#$5F^8OJ8;D-zB-x%K|Gq2^{=ct|Ma;?krORew-DRAWb+l)& zWWAh#;ys7|d#{`LdKqh11VkSi9Zalw_^{Oj^f$E6F&%LxcU*4_7%^k9{c8eq5HVt! zKxaN+b*&3ebE-$gM#tDGQ+Tc-Mr{5xKZ{KN>UoK5t*PdNBjRXlPM$AT07enB#xm@b z2}{^1A7CPQiSeAJbxbQwR>wdF()6-$A8#4P{wRzgTA6E4xt6piSS`i5h}Py{kFbLM zQ7v2S0BuM_9auE6!xMi>W4}cm(q>ZBff(Amq0fL?k3M)>Si>)=S=Ps@qGO}2G}9aR zKj8*bFjmAScQRWMn~{c@2`LELoUg8k;Zu~|XGN>+Wy?4La^%ccJQgjD zLNL~*c#>#Mz>E@otQy*th*f6z*nI)+b!5Fdl(fp%vfhCXed7qSa90CH9UwhcZ8{Yu`W#q_IXs zazc<~jx>hxSUN*a9b!UNIx8jeLKa%tvE)coZ=m>Wi=0UK$WyjM=jB?=P<@(F>LFq-P$e88Gksucl&b3==V$#icrz*%D?uqW?;CHS+&0?Cn1EE)Z=B zRbQ;W^hnTBpqQYKy#NJpKqGs`?HF6!+V7jU-GBmiNonqpn|p%Lg>H7-B3<=s<*L7Z%=|4e zqi8h>N{=2vt!mVhei!c|2xV$@wA4l7iz3k{w`L5G(?bKqp%-;{$mj|&$wVE{{-`ls zMpI)<_*1Y$0E*Zs(*MhAFFG9{s!pD*Nc6=G1xBD)E#g`Mb)hK^LK}h#R;gQ0Ja+iX zc)I$Tm%^Q_(nI|tpnf_T4U#hXr}xt!#6=gT2UO<4^H3uZInR+~CA%hQQB0L^gG@A0 zpB_C94o;d~G08J^6zBbr+oYM9ZfjYwB$1qd6!whucz|Dj2mM%jmK>%elD-R-#&{Oq ze(5>5lh!Txin!?KWVxO!FKn2IVR@bm^GHr65Y+|pV=|9FQ0cGI-Y@(JpVxngX>~DC zo%_MpR+TP&ips>0I5rtF%P#v-8kJ*<&FE}0*8#j)D*GSP{QN6&7-IK_^aNw-V)HIz zA@ksFRYHU;C(D4P{Z)$2rrdJ){Io6(LtpCDnZ~A0v|_~c`56@SEt)<}NS95Y87obn z#kVYJ&o^9b2wL(kbZS%S4X9|uRLabwh!~|BMYsb=Zs|4u>svT&kV^#PnF8 z&K=l$SlN40-g`3OJ{7Q^UNHGk$gG0tL({tgHQNH|+e6^z$#lGM=<=b@4e3+Pof718 z2R1^$i$EXBvs>15xs0#38oLTjueX)J|IK`3m(%pkf{qwmyx}y$oy=YeQAi`udP>ZW zOV9}#@t(u~y;WG3g~#V#3!T73H+79}63qw8JVipB>?y316S2!`@x`%ok}n=- zORu#jnFMR$PwGwSvH8qBc3&c!@m8*B*F)8`7E4rLm>0D9i>GE#Rp{YEq=UC2=7^=W zNMeyI)nXEM#Ra21<^aJ=%5#C4Q}Q?-R>P z#@ZiaF&#eJ{smnV{r(Sho%8!YjL;M>{>o{!#SXV-cot}gq0z`wkFHtPi}{DeAI6yn z-5(!H?(A;6$GQ%&yckum=WVCYz-Nb*herwFpgRy2bsG%sv|JLq6xSP!Eb~&qXs)pv zZOZoHMRlCssKv$ZdWI*R@?Zj+z>2Upo|r@eXQ8|g4p6VE8rz{7uF|ozthn7k;+rE-PpHY^+jmjw~I%H{qL^Eo&g=v-vjt@ zlK3~jJ=Xhq2!$Jb4v!r@I=1^FdUO_qy^v4z!JqMNNBy^S)E`9GgkQ>iH8T9xvgIx1DQe}NV|7)^>S5)&N_Mh58JRat1=d$(Hz;ljxZ%`^ur~?^zgr}sR`Do%RdC;5qN|%Yz$aD0S zEfb?ji&5?=ajzm9L*8%T4N8xE9d6l?ufZ)p@}DWhQ&xwM*;g@mfS1uz;VCD>u{;fT zb2~-y_LT3Do+(}fnlOzmP={%$4SYHE*ha~ zp=w|#s%W@$ui@{`&%SC;b-1Q#B5JDUzM5)R71p3$v)pUhcUTQyJ@Yw4qlzbs=K)dT z*uKxGU$n>Jvq#Wk8m}g1&6w_qFf~@|s~hz4(!1S_22ipl(Q0(a2&DscE6*5+Q?pDQ zZA3tv)@9ohGpVtUkc*XFB3=mj(N*_!5KMtl)q(!>WMmcJBo9X!rMl(S=na&B2B{Vg9O{Vgk`?^-5(jXoYSt}*)Rfx;cCISqLe*?Z%o>c=+@U-=_E{hah1HNWY zvwJ(WkybvSE$;0e(cL}(<_pBbJfc_0rl z_!Q68qs`lxmn9ympWXdoi3Lmle}lw=rN0+$V*fug_bbfpU~Vnkg#MSp@(hK?47fJ- z!}bZ>t>Cgb#nS|rO$n&ErigbM2_wFf2tLC-?tut8 zK%0%|jZ~C6`VH7l+}_D=E!l}1;JXZ}es$VCGmtbwT#{U6NXH~}cQDneb<%{- zPlnz!{UO*<^No)lW13UPe7Ms!c68!2V=y{@hD^$hgV6_k?6|WNN?4g573Wd1KS#{% zl}v=-eiLHXv;i*n4Ad1idF<@U`8J6Bx4g08auzSujl+3)W_f)@<$XBUU^ib4(- zX`-Gv8+0^WiVLMXE^k2S2k8aVBVXRDq*u%7)wAd3R|gw5hw_{=jw_o28P%ce{7Y5| zK$Ah*NlqU*?42A`=o~nUCXa7)T*{~GTypX#Fk!Fv+yMHN^hs1FtbotqBps%wfrJ<& zV$ze=l5>5&Dc9?ueTV}yZ!i6 z8DHK0!^zKYnoK?Ds|=T~rs|--rg!_K{jsPJ3H*$(r$~;M99BOg* z1|DDCQ5d7dP;sgX@;Kk19Ilb`UnrZ`$oF;1D+{TIH&~Iuk@FDpA|A;}C+EG3ye$wP zl0HjG(Ko+n*VQMhGDFgzA$St{ox&N=s2VP51Sf)1`dcazlNibr#6{^r`5CGdtI5%cT^y zlJiI8yhsiW4eERUA^F&VmrB0Z=tcIB?~lm$GG&}i(o{#G^T{_9?yAgur(ab`Q$~^h z6FD0w%W8UTfG!zVLJ53}4oU#Lg7`c!0v}P8VoPIZ{Y;+?#Gjx}i)M_)T#5*dSwm+> zNORK=vh;2}{ZP`NEf~qn|apW|U^CXqxb#neU za<)^Lo18c3k{VU|b8_CKOU6h+z|Epr8dE!aL63xK4kDl2~PA56kEUYOaYFigu z`YPRJeM_t3MO#D%W0G{ArNXC>!^XS!k?dju)xzlTIi^)-F^N=`qxMEl4f_{uT1RJU z&;4wyTC^~QTTI|>(FWE{K8n+TvS=qB(o<(ncoE9ljd7L2(kmu}Mk?YhIE$$y8>cqR z$&vGCq0MX&Qgut(doc+CygAen^`EJ@acWvLrjjJv8T7xA;l)@CcZ=3=JDw&3vy1WS z)uJsN%Hv9pP-T9L%EHWjuqOLEx_p#we207iIDQ)8oWI7Qa=D~x;{cz{q`yJM-?1X) zTuiKt$)#`^CKrvRg{+P;?$83+T<{RmRir^@iQ$U}DgufSnqnqLdm?1NEJtGF_8Grt zPXC@c_50>VRfej#nWepAp|qFAeBYt>&iDAiqZb=P&P(mf>OS3MN89tQtv zu?13Rd6p>>SIa0-(pSsrtApulr_6MHzc3a;v-2R&R0u;>&#Hlv3e@`eOvyG{6?rjQS7C%y;QMR&&EK(3OIubQl|RA zDq2*%fYOw=EtpF}&bkFN@vcI|>+y?^-?Z8Tsog>Aj!<&WbbG*Bj)!b`WwO`G95Y2c zaq+~bA6Lu{FttLbQC3?OfCTTu*3Pn z)0dx~>77ecvRf|2f;s7G_Lb}z$Ly+LPW`2LaIa1udoR5-l<%5Zdu5jbRrL7{a|!cl z!Tj}?w%)8-HMjb^Rc%+2rxRu}Lgm#jop|xY?AiI4VEMY~q>!URan#F>`cPG4sCMlu zyI$Tk-*#kr;pHj0&uGte9I1sFPG*sF^ zwoT`@&2I^oZc<7+<S$Z&bF;*L)eiD z&Bi~lW@lWs<+&^fj1 zrlaDL=~jB4lHQEpyy+X}ubHnEDy_Ta*4;2$GCe-iFBdfjvztQ=&2#;7L#NWPLvGk{ zy)@XcE3kV&Zg49N2jqqW!G^(U)AVDqqZVCYz_`z->^UIsIS_dCgtBK;-ZT28@tLld zx?k*`eQbUpSh`s$-6ofAyEcxRSt#lYj6HG5irii}clq2*ijv(RXE)4sz1scP-SdZ( zmR`A~H&k3Ub6PHLQB4QTw*-s9R4f;FD#bhG;vK=_-b+2`KowOlo%oXzvro@I8Z7U= zv@3+`bANI!(Aalh!fUnArwRV~W~>ie%xUOyG69b9l6yy+;snsg;8P>wen4jdQ>j2sJ;9$#>r_$j_x zCpAjj%Wbda2Uoja&!6vD*fa2|_qBX^wL4g|_fpq%p`1}kpLf2J-hv;Dz+COV(miu1 znBT1Ax6MgP-ex&(b1-kqrLItA^W3;xxlXC{%#VNL6JPtp^+$r6JOP-#t*(Ei^5x38 zjlt?I3*}qzmag)bDqpOeZ4bKE%$EjS8!z>~pI!OYp||oD9nNrWYn1dgf%G*z!+k;0 zSGH;{M#*cF^V))W>yTk?;nm73l{4+Z-1|<$>W)N%?H%i|eM%H}eXo&&`(0MUBC{rn#A$j+qV9jIGJAYKt5Nc?8<@{fq547#Pel+0r zDDK0u`*5(qJ7aynsODx>{p{09Lx~?F&WSfhQ(@e%nUT?S`$!e&gGHuc4V9Q@V!a zuK%aFZvl?#O3%Ev-|B9?`k{As>n(NbErb9e$r2A4NgzOtY>Yg{GRPn!HMkqbhFRNq zGMj;z1e6Z5S(4$$r_cS*x&L|p=l}n6@Y04i)-9f1t`HmgqcxALmJbNiGoMR^ zl-j|S)m-*m^emoPE$x(?6y^G|2;7vqi0d2Tzqjk1T`P^T-k{hU{LTxPB8!`r_KP*$ zQQwBu;*G)+U-?`rrnDt0!J3vMs}-B2Qi?27BN-LTDXIdsLF%_lyH~xvl82(a+*aIY zbfe{Zi?C-hw&h82%afNH-l$oeS+EcU5OW&%m zU#gRimT;D`F;G4T7J+X>^~A4IVz4Ey>#S_LrebU-I&3n z_1jmyLqaJ0xl~7~>$$!B0bXvm!c!+=d!85fJpV@Vcb!X52pfjQmXWA`bhT=y@WR(V zmm27WM$X%iZZm#Fog0>#x!nA34V@pF-xRZZZnts1O;Tsp%!r`{rt@^B+{gG^1=~NW z>Am{+_YS;sV5K59{IodywD8Pav?d($gy*e4ae7h~={lEtmYaqCr-Xwu!c)%)mB&_H zv$w6dJ9}|F<{uFK15xMTIZM2}CRW}hmUk^b9xd;Sx%qlbsyTtlkEah55 zlae4nehowKkJ$q8K$oz7hZq=MsS*P_V*${3H>GG;yZ5^7e}b_x+j-mk(SS8$E5T7u|zB|Y)-`nV@>v$AG!ceJvL+3njXR&LBt)!u8Si>=$S;<{79 zwV&pH+IIR*H5MaxEL=|Mo&7w1_~On0&vozP?+oZD&dzxwQ0T^R?SYv!5dS7j_~l*5 z6RP$Id&h-6LBY2#Rxt5F!2}sv!|%VngTCE=+l#BRKTkr(30g3;NTl1tkDWjE`*Sf% zxo9a*X~i(rn`@odrmh7A_wZ^;*L(E+t-_wYKO9=wD0J-;^CkofGB6XSe~t8F_5Jn1 zO(%-^>znzxdc*bJK@Nd;`v$8*o4NPA{G8wLo^P-Yfxk8IbIpdoH9i9P2H-Zs4dY-l z0xKQ-T&H2Bvl#FXa`?IRh96i5a}fBu;%zD%3?OWUk{Hlr%Z8H3- zdXV1!mpUB^{4ai<;AZCH+GoI>%w5mijm+I_Lj0$C9#}s$SwrjepB9vaTJ@hcviMdb z#jG<>noX4c)6LcsF8!~K&J+3iUzhO5%T2#7?FKC68xb$L^vL&&CtD(%IF@uL&=m1t z@3{)xj7`q(;Q!tCz@1a%!gaKv&VE#;;DhvbAX7~n;;iRCKrUSOK#69By{Vqm-FNPT zbCrimG(+$F0aQrIg-<$E0(@C*AV^6=B0u|4%lN?j%rdV&u=rY3 z)(7sLJ&FSd*)}>IT8vM(C~qhhyxB28`ov-wjMQ0}0-pvC9LZ&4;V=ZB)>?vzC1S70 zxY#KM^qey+B0cmx1KHE@No9%&mvK7skaG<((nsUX)b0%nv0p0h8U1( zwq(M5LO$66ga0Y?tMH$ou3%&*t#I@uIF4GDfQo=8e2RE5gi&UeVc80k-{hn?eSVY7 zEQ8_B%nRjG9J7cCZ_B(JnH`f4ygasqLeE08P(OIB`9|CIHeqmFC5)?XgZbFK&6;2Nn!fGG)a2@ z6m#!gn`FAgsg^SBtHbbprc2yv8W`BXB*Sik`}e3oU5Gsor9vu|<{*BL3Xqn;(#iDu z8WWG2v+852y_XJV7+ZxPME0Z7;bBu%$O2Ku>g+dhI%yhDO-|`w)d_S8l$>l(<)G&BE54g$hBGn2yS_o>U%K2xzAW z>Vswjhi`^)oK{mPC*!WV)Lp9(skT05i|7?2qkpd41q-7p5dH1U$`V1u68AjoL2P$c z6oQD|czolwD-{F;p!0C*m_mWX+H~FfBF{{dSKZ!(q>^BP;sPveAAY6 zNQHvd)*Ohazb%V!h3p|~$cFLIHd-y;iudo0g%i1&qg(Fy4mF3gub%sBjh4UzG5PEV zi$jfArhJLnlpgiF@9X(_xTUX3PLt`bR|05h`6=Q3au~DyH_h+J*ZK}x41#7Jg=Ph~ z*AMq4Pe6h}tF*W;%sx>N%-v+XuZ)Gbw-Lseg88j_sEnD$M>0z)!)hCNR5$g%(Ly`a z6cZ2XGdNy7h1mgyh-~9@_?Gpk_@RJsuB;S>A58U$3Yym0NQPM% zxHVdqc707$XbL}T)@{(OQaf10#=lXetR9rr@Q~TTIpGNwZ!+9ROUFcEu!Nd4ZhJj) zf2{;lwKCyQ(m6EKrS1fM0G@KSRx{Ls&`Qw~weh9W2P+h@Sku@h2%j3qt}?+_eaIHF zGSOH4*n5g7Y`zjcla+!W`)egdi6SgplMbgLuw2eFmW$`ihgO&*t{fc3onlk7r?I(% z(Kx^=--M(kBd1`&8aK<~?m9Bt26l^lJKl5%!Xk&JN!l~g3M!Ak` zCm_flq4lnFf&&n=@F&@3=E5J!3)om zbAg-%a+r+Ncaai8giJ2wX^N%UTb7F3K+qv_o+SqZoGdUH{sq0GlkKyN6Ko83!($hY zCF}~j>L3&P8AN;{SEV0H8#$SS4jqA2y%|(n!aNZ=7CQby2rsgC!c@!%y=cfNRsN|EBk(lt&w`LqBvFtm z(@~V^c=$9cN5Ym-_u+J*j0x-P?3`k)F8qBIHUYEAavjXD>Q#PA6lF!rxkeNzs|2p_ z3V#*yNRd{CPY#6Np|Af-a=rxTidYV^5LkksPl)m~7=$kqv`9|6wwZ`nR+Ya_iT9H8 zG&xxn{tb#dL{3(%e@2*pNeRO2gkHO8hPsqfB_u@6DD?OOxv{%5{K{ z=giK)jvraRPUL1vo;?%(E5fD+wuEkWCXvgkpBei@4Y2P?SkV^Lm-$Hef8Yfsf~dx2 zSGAH)PO43@7Md^}f}H2G&nEJe7Nx#!PlX`|nBc>Sy!5w1_$|V|7t+h2BkCJD1c#Y}cOn5#*2HAP)5XXzR=vVPqzmbJ&%_r}%_CKnE?os^BKN@p2LZW2Jy;Li@+E&JKv6jnpc4smG5rc)7R^2Vh(pldMNj*G zfAsB-gxD@9g+djs_J@ z<9a%=2wD|Y>{7zHXmMA(s(blGv1%Y*y&+b;S*+fCbvRl*6fZ1)&3nPS2*aL@vBEK- zc`(+zU2NWd?Fd8=(Q@gy!DKYunbwmyLlH)xFYbD&lUJjEd;tLj_b&d932pm}yG3Yq zcU-8LFYeTG4P*Qr14#D89f&Dy=fAjPV5LU^zleN-@%!t8^^zNPm-gX!-qgH;Iq(9a)eG5j!41o(Zf`arwk{pwPJH}eP98{Y2? z0RH>>;sYB^|G?*y>++D>u{|F@taAK;e#0tXM{s@7ByU;m9c)10$2I(<$?)S^!usQ$ zs>vKftlR`RR^^(^GsHG+gd6WDLCi-v{G`+Hk#&#)iCUiA1~a+s^g?1I!5{Mkf1G23 zd#3H)bAOtHTi9HScoOTLIs8%^PG3$yzoR7rHHdp80W2WU@8JL4A*>@5NdP@9GqRtw zE?I`^kdjVwlT4V|qi$(5Z;7MT3wyLqekXXw=18Z}+MZ@xjniVi=(JdYjGco?njW=` z*!c1HSe+;7fQKF3WJ?ENQ!77Y*FR{Wqnq?SMcQZQ8CyNPjjf(h%K~UdDy*m~xu8)k zGaVlVO|&^}RPrYK9YM1i3*W-FuCO~tKJG!Y(Pk0(XdL3)pU?mEk&St>S}72xbx4D4 zc*-U!j(04n()VDSZG5O5ILHtL?ZMnh`0}HjUZv241(x46zZP1m`RnvDJ%R;bgVOzO zIj93@7*FWI{?TepU$TCXqn4zEW;ucm#*n_Z?ZMVw(8+4dMNE?^uznG9Z%tQ$5^gmX zzFgB*xu#*OuTG^<+Q&Uy#VPg(YYAX^v40OppJI;P*EULJ$}w%%*T5K8bGiR*Z zrVpPwG+jx9P9+UGOzvVTWAiRwPEI%AyeMBjg&q0P=}_g)ox4)%Xlcd(;YsO*;AV4Z z=N%-p86eZGY_%$z@qeIA3~k2QK0Mrl&rcXxZfVuBGrm2f>7=O6DQZVx5u_Li*j``3 zi|p<6YY7unP$S1f2`eiG6YF4lZD*>7L(f8SO2w$`$60A*>5t0B@UN&$C>-sk3t-P4 zRKkrx_&WNW%>BH)J?xn&QAW$&5|qUgBZ6fF>+U?y!V@u1r|9X7dG;>P#JWet?oog%;b`vo*`c^O_uTQA*}GQciF+XO z3H63}sc+FH;(i`)tx&gK^mfOKHl(FLA=g}3MzWjGV_B?@P0{?8SpGUOe_b?x z{n;Hi^F6Wrst@w378|4aO=oxf#GF4rj72p5){3g*9`DUk@4{($uM{oqglV_eaGI_iR60o|wxoy8JO$+tPSk4aIYdzV+1kr|#L46mS;D ze4}VC7kph&bJx|fYrA8c_N^6_#XSv6`J!iIytHO<6nB%yy^SQ+?(K;eRlXLw5L(>7 z^l}susHqEz@ZHYhc&R7uE`@l1+)?pb>xI_E;;6$PbLl7;nmP22hqxS{8F_zX7;gQW=Vzy8(fQ|2w?ZJTbt-Zi?8xI` zS{a4+QkTM!2N$MkD~weDwB=5mK1|w;hr$kud!CZ~H954ZN!Sz~5cDC#VHd^ZrMN<0 zJx=O-Y($_#q(p)8ONz!RC)?{2)#YMqXIrhJt1eG{bj-^eFTh%43`y#&M$+K^PMy z#uqqXi9u_1I{XMle4U(Oa{hvxNpeC&iLVg!O>%yZoIfDvkI1=7&R>%AKgsz%IX@yt zBqvHvjGSBKtdaBQ3sm=l?}PE0M-e&Ja1{ za-T-_~h!!53f0c}Wji`(>%IgZ~iKWmET6vT4MKFBG9GF?vXS(9Wk@SThL#lx>d z0T19(BuU<91suNx47YN=cwsup!IOHeeBt5|iG#OXlO#_ntKuEYFGw7`Yh5e)mEely zdVi9F(hj=t?5rheY2bbHr;{AKDBT13;rYrFKIRKEepU3FInK^eTzAS$d_!k@U07Y8D6r;D8ulQ z4js}154@z+%okCK;3ZvpzM62tOWF{* zU-^vUr2$62D0I)CykL{eENJ0e#ZnFft+)b2vN6!k6QnU=<5nzkT|n(n_JK z0~6Or#omRle*2Z%IgNZ{yr@pn!(X$yBqMwj$5||y34&6DJ4tEo>N(v;Nk?&y@&AeE zi{b^Y`F$6xlAZ!*6SIf?A6)M-AEfe^zwTepU{KYG4iJpbdxWjqhHxzCs%GzD?!SlN$Jq6YZ zOBZS`cqAhQP^N;C1;hE5B{K_JxV)nI!{?upa##==kol60fp#vpK+0uc9+4oQfdxbZ zmIo1Gy`*EYdsHF|xm7~v4kGg~$Xr%+FMZAUP}F+0L|<-VjfMb}2}s$Ggmi?LbSyee zfZa>G>HB-NaPHUfv(UdqLu3%c${+BYXt|Ai_{B=_n4@NXfM<*nLsH zG6O@Uf&o6m0AIlXh%i)1I*J=&7%E<^ICp&M_}L19?+21>(&tPV(YEQ62w1b1O5i`F zS~mmb=1m|We?-!;*b`PNpXXK2xmTjRPm5MwKEM#eXS9N^loJr4s+4pTw=Mmp^}+^L zp?kFO6)4{gpW%bA-~&YX_M(AMWMhhQ^~)!P-ksuxUDQg(w2(f*^KMp30vK8X6ubly z;@v>ZqF+q6=LTV;(w;SlN+S%zO2C@6BsGH3kimu@nWzL(2Gq1!^Z}Lu>P*P%6t)Jb z*7kvmnq6zQJ=9^S)(BWbhcps^V&|4iW(HcQZ$k7Bu~zCv>=nvu0(r6MXIXPc4GLJ+ z^Ickvqlj;2xxi$&RtDs8xBuA z4+C@=mh}$ZS<>cI^V=Qe_rSh_lArDARDKL%`7sFjA#bw`!>U%klq173!5K_>Q@fOZ z7($2*-GaR#$~S6}r&l2ld`2Gl3V8q#Hov5!xR+V4!TcrUdZN5n3soQVGugC707FKA zf{j2z=aY0Sb_;8Hm{kOO&GI&3)413bq+YXct*Y+6$;8}%QYzDj9xy`=U%?HCP*Y2z z$bIRe)(Y##XcpM5Ro-s;^ae=}pCN>=AOu7R_W&V9hSLaJqkNkdt`~Uzh}=jR#PBgl z!N_33*)HiAoII)Lu`WL9|P#8Z(C7wlGd)IxGu$@%9IsvSc5P)3riiDyj zF}GPVvk19AGEi0L&RMF0Ijyewl$tLAEMEeUFZE6a z$~}NU$|i(tSae1k^hf!C7V$j1gY_@?jBxN3vH>DwO_Gk{g6m zHUbrNEcU1|MfT1pzfP-Y2YLP(xn3E>FfvF%$za0SCF$h&9gM83kWEp(SqteFm6ilB zlmsXk2_%Fql8(iWWXu8R!~dAQSTv%^$Fc5Z{lel(QkdRHzs>3l^-;b-t2o;Y&{<~{ zz#vv21}P1wfTON5=A{oiIOOaluU}0MF=u&Qf43$l4w&Py+PU* z6FY2tZb~I>O{K{(Ri`oYXhWwxH%iZ1=O}IOiR>h``$&sz$N~bZj+*-PEU$C#T9V}^ zb=tem{r=exfZ(Prr|n%`_jZdK%$_}aX7+q`|)DP%+{eXctaP-_TXdE!{CKfjing=Yr zWgv&o8L;x!0UK{)>83&ZKrWxl;^slefRlH!xMk2akjLi@xOq3b&l&U#czG|2TL<$8 z3itvRw+$8!_;??S+XssV{JfvVa|eqDO862McMO&el<{RO?i?&1;CODJg0EoruEENI zD!yuUNe| zhSc(m)OME2A+;hSbq7nWL~2z=Y6nZLMrut)>Q0tgi`2S|)Lnu4ac7`me0QL6+%#Th z#3y%{0!@C+V+MXtpn1GA&@x_zXPx6+(z90fY%hC8>HAo^A<)Lsy93)$4ohPt?v|cz zXHWMB+5F&tt%E%~5ZH;@sK#`urob-t>|kIw()Wz(@vIAPbh6Z*K-YK|OECoY zvXn#0*X+X+u`I5`8t7(Ed&iqbE=fb3@e|lT-W)j4sTyk9+GP1LOehHWH{Q)&7Vgl(|mNE zn-puDj&h-e`Pmkf$<5Dlm7%9X7c05?$9Pf=}WXJ@B4ls6TgL1jE%M}bt~eCW(fc+-pQ%Vr~7B>c3}8dlEaOgO|-zoCjJ zr)L+Ua$DOtY8E|V?V=Z1sr>Zh{LDpk%M%Oeis{fy7k6O+pU9dvxxn*C<<5jLEMc)f zrCP1;5(*q{(4{O>q51Iq^o4NBLgC_UB;24)S&q-mPtQg|GYy7R4u*YXc40our_6`L z7|^L`D);EglMnViJsD=%QsxnAKy)5&<^|^YaOeV>Iuee~%|@bBoV_P{F)|rIx1mr} zYKn$u!jtnULntyef#kD`MStSPbS^wUF?9wp7saH{ znplX2d88SF;;&PJ+JFQis3r#>bCLmHDi=`^P7Wz$cKU!{EWd?*q{e_fam!FB@iiO2ED-nkIqbV7pP2@!};e=Z(QgrCIA6K58plbK74 zLAtr;#?<yRyA;LYQf@f=!dk!F@BmD!=91D!qRg1+;IoWu(CTF?__s&c9+Y$*qwvjIr%(x=aQbe*7?9Rh` z*`1f}+CZP4(DI;BQA+`PUdR`+=RS5<#QWHtpY9knSPW{gWTy_dxcH(#DepgLXedj0 zFf8(b$?)=S8}jt0Y^+}+G=ZjuQ%<@&M?>|*%)|melk#pz00=}vfMH)|>Rc!qeF}JH z0y8|7pZR2x4fe$J)Vp(q8cnK*p3Tk9%uE2Wh508#Gk9fk7J&Y4Ge#~|NY80@@MuCf z98sQ*;)^n$0;G^fe)m6st{3YS-vwTfpQTEuNSe!P-WnxP4;ciG|RN^u#$a!SFlKWH>U5$4)}u6QTKe ze)PE7D!sOKL3Yf{$G-1Im)qIK?K$|Sae--8DS@jq%t@L5gL zU-o$?--9^cO98#yP|WY8IQ0?VO~HN&N)e7(;J zUrx6i1r-!jAxP-~jC>8<(lp{5C}^ahiGmgi+7YA-Gt(%dgYI`xz?yZCV$?RVsfQ?5 zEC)2fqJMC)heH*P3R)-)^~Um0DUynH%tXh=4;#tgpI`n4TbTH=P*WKCnt zP_jA|H#8=j+G2*H)jeyySGwbdHoO)yl&(hUovPZHp?EbAH`HcEE2=5~j<}&ZS>F^h zaBEd@LsMq77G;;M=}~s7bbrj?T|qVbla0+WL*-g;+|Zn?M+Wv<em5br|T6L05@L*ve1*t%b20TIieQcJ&X<>X5cNtF+uM}ge;@p zG&tV}Bxk@dn^pcTXBC?{JT-&wqkzq(eu}Y)bc|xkMBB?7D2)QDJNh>WFoh~>F@=1q)tAc%@;`JMWzDLrsnoWErqa{v`YX@G4Lg!8+i5zD zu1#LSjNG1moAqw{E}A3t>pQ-R`MN8ab-S&DrkG{j^VQtAp(9y`dFWj^zdC<8LUXZW z4^6eB>w&Kh#SMEh-@R$rB~CQ@X7!0is4jb=jf1iY=%}|@Or~&5=nOYlRpEqz1A~qL zUJ`Mw5nM)Soxf8qsd&?bH|cg#nRE`^D7INW^WoQ#4e%C z>Rs|ao^0JESo<pIZ-y&4TY*;!jJ<4 zlZVk15wS4P8Kc{i6wnv&rzx1A;1d)u+!LbM843sv_$dk)5@En}kZ!Y}jDU-$fXa-n zA^?{;!-+#NU6B@2p|9$21_8;X8xHv_@%FH8K{* zB*Bfif*JwsmT`lW#$cR5JA-UiiU`lPal4d!(bM-imlQQ`vUW{JEfAq7~1RIU__ED$w6oU$?|p3$Ev)8s7p`)JC>hbO~e z^`{8(yhPqlgl9r?(F_XT#E5Iq+v4xUnTrzDoywJ{ZvyXdRD|a<;7Y?&BB17d1fVkT ze2l(ZO4d%c|!x7LMv<3Afb0d~_vn2@uC6^NI+B{0w64u2^1$V6TiDDw75NxWS+F6vhpO z5(wTj6pH{w^{E3?Bc6N+KoJ

xdZxYf5=Ue!gdtCb}emc=wYiC6$vNOqa@7Q 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 N%(QFW04{<@{vVD!ynFxv diff --git a/backend/api/__pycache__/menus.cpython-313.pyc b/backend/api/__pycache__/menus.cpython-313.pyc deleted file mode 100644 index 598ed11765ffd438fcca3868ae0241b02d0e0b6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66762 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 diff --git a/backend/api/__pycache__/newbook.cpython-313.pyc b/backend/api/__pycache__/newbook.cpython-313.pyc deleted file mode 100644 index de4db9aa771edfb643e802c9a31be0b9954228e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46976 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 diff --git a/backend/api/__pycache__/purchase_orders.cpython-313.pyc b/backend/api/__pycache__/purchase_orders.cpython-313.pyc deleted file mode 100644 index 9e6c378233427e06cc7a5f3b9798e9ac4696e63c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42462 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 Ko+u!rW&c0!sP)4D diff --git a/backend/api/__pycache__/recipes.cpython-313.pyc b/backend/api/__pycache__/recipes.cpython-313.pyc deleted file mode 100644 index 2311f8d7e3c588b425347a3e8a1b4c2181d2c510..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120951 zcmdSCdt6-CeJ?sQ%nb9M0fu3K0fzTWfFwWyy#WbHAR&p-$g;u4JVpo!26+$Y<=7#1 z8lyO^Q0i2uQI2rp)>4}u<0Pk+Z%=QOM`Pu*y=8k8@9{*(@oAgp_@8@`ze;X+P&|n6=mXt@m&J)^Dx#TfepT1FO}f!n66!pO1VvqE`I_Ii$xHvpg`Q zt5jc8`Ba?Br}k+&)ts8WHJpaMQ@9koQ#!RBI!@Q2<+L4oPVc3#+RoGt17~1%U8k|b z#F>~~-)Zi!a8_nd?X-2+IeUkLb9AI}X&vcYdPfGA(c$Er%-_(N+2P_`%x>(=>Tq*z zW;b9Et(_$urCceq z+d9iS%DHl8w|7=_RC1LaRa{j^HCNrShFjB7!_{=uaMv=v>#ao?G8h z&($+`T4zH?BiGomf!olrk=w{%=?K%rHFY#|&CH$Axv66_x0%_Uom)D#a$7sLaodC%4n9>P_kXveX>%yGYgB-l!Jc-0og`k*YD~$?frF zaeLjWB9+gbqw;0<7B!~C!gVlc4xqUS&`t*R0GgKo-N&H$fEFY`_cN#$(82_07lRf7 zTATnqz@R07mL@>jAA#fIh~c4S+T#K#%tB>He;acgg14AYs4P2~2C^E(cm>>k?O~7Fc;M<;*!_&xNtCX(%{!BT@*x8YkdVihD*K$bB z4dC61_uvZHYLl?e+Gl6nwHofIeV&NdOz+#3l=l$wQlf=;PwZVSbxk~Ft#5Zy%7GP9 z-V-l3QQ2N!hp*GOuQ_Gad{TV-(MB=`yOQ!cwnAP9mgFT<^iuj>?m8Hcw_5G>zC&@^ z?}n|9^#WfUcDQ#qUS^BWpH$}KE3~p)=GF41KCwTZy54srDfN*RF!IOA2EmHEQjud97d9I;8wuUXURdv z(21nfLn~nDtc{G&-ZOss&Ru}91y$S`y`A&2p#941%5&}Yd(#?Yyx)K9Na z{%0Ps{33>qB$S^^%HhlkIfOn_4v!_|Fp-qQ*%fk_{7gCc6LL76l*2jXpfn>p6VD+7 zqx0FgYbie@*SWagYFtalxa0ZWblo;aJM z_WL}3Q}O(0PHOOdAt|3{k&n^{^FlnI)#{~TdB{}wVp8g#^L?@RIp6Q}KJWWd?{kE*Y_vrKYxmN z-)3>2M=$v^ip%|;?>{rhbK=UgLF;>i!7n0*=NmM>dFGm7`DuJ_GS?+mitof|=}Wzp zNbS4K=c~h-%C{%KfOVazJ1{tMD&TE7xYzslQ?Gd2y8S!Xcn8Nr6C=UlHQvzVKn*8` z7(6#P8t@Jt4-B3Je6W9%1sDqiC%vJ-;KayyFto-*5xt>c|EbXN@rlqH?g|c#j(lJQE#>Pd?(xZq07qd@1%gANsCCcb!v}Yr9SpDt zQIr4F=y?B7`^ab@s%56`&}4u+hcqoz!tOxm6eQo(wtbb6JYN%8(;hO5f7C^tI>QzBBsv@x{mr|>mqo8nT)~<0$($ zH-wt%Io7|TmRK+9n>cqWa06Y!6gBkqQJlWMsHv}SYl2ivrpo431Cqvwqpv$B|$vnu5;AZ6sS}AE9kxWU#M)VuBkPK*tFo zR1^Fj;HQe(`cIAYiX{uxo;t^^2ap+5l~68>msAmx^~JprSJqU=OiEar6>)o~_RSQ( zr}c6zQfOr)Z-aL-l6@KuXztb1{Gd10m%>o00fKeC#u!XOx(VD%zXZ+xQ^YntT8p?{ zTmxlJ^<~wmG;_^l-$ce{GN_5gK&fP-84*V%660(q+YT}a8DdRZ$+lB6D3?$^3}7H9 ze`@D+#Z3K$ny@xM;w=Fx46_-RtnX<{#HbWY83_mBlZ*tJ(t7ovGKK^Old7J|;1a!w z)MsF*GJ^U{mF8$h0`*B8?IS`-R5LP^fSU~fFOR3MwSTn?kNaquAr{he$z#bzkoiCMP(7>5{jfiDrE)!>Am^U+*j-s;8Y)t(0yYgLE;|Kw88Q5lS@H5aG*d) z?1oN7Vv;-G0Th#?KEb7u!KmyI*?eU9$v6Tdsyl}9b%J}007uDqoQx;P*g-}T0q!Fh z8HC5s%P=Iql~pv=Ig=6A7DY-crw+^9vDxET!x1#Nm$$S+)De+E`>@^&Sxz`lqojOKh8j!fmW5uqZ0iny` z#IB$Lxy#{12BYC@fZ1c|iJe5lITFH|VtJ$`q$hSC4VRt(6T6Rw%SebTb{`Gr1S~U_ zhuD2IoGT%m*nKoyRsu}yJ{ryqm{h(rR+e-w8)HR=)TLs>#2z3#HcZG=r$B2;L>4uV zg!=lq!Q&&R14GgDlS83C*84^=tYYWBov>gRImyxE@qYsnA>>Jfhlx@$pgaX$sR%#sC>R$VlolPZEp_LR9GI zFqVyUOFPMG9jK%N9ca(2@6!ITwj#MV{8l{8`z35>XMsDWGz0dY{&XPqVCnj9f!#= zBqpJbuvTeTYyuXC;hZ%An`W^j2$@_`I;_2RkVjHGj1i0sDoBWWG1|dfGPP&=_{`*m z@vycesVUHb_s=xV?z*&{M!==bfq2Q2T1r~ENuGvJa+5rAZ_%TX+C2Et}AgN1GI?DJz2A^d7 zf5t8$k$+;Bu*Aq;Y7;BS-w@4=x55(YA4eIwxDZT^p-4syBYb7v!-6LfK8wu=Uru60 znmxWC?D#U#xR7;bt#L1aV z@EQ=jiKII*VZtOb?~3Zc`yt3Oj|8#apJY>iaV2LCA!uK4a%=#?1;@}N$7BG~G+iWj z))2}>{wz{YplZf}o+B8;{PSdEt+9h_NqpxV!N{N@O8kE*H%W7bwa!SETbz5llPAw| z;P~{I(o=4Q^YD3;RzL?JF~XxxkEJQI)KWA(mf}kMpE`h1Y?aug@^dIm7ss$iR*vCJ zM%OB&uY_?qiAO;D6y-+-#R~0(0n(Q-Nn`5L`Px!f()vY?7;gefTe%6!;FH`0#IG5@ z(8q;lso>hWsF{ekJHW(VAH9f}Q3IpZks)!7*mc98#CN0G(Gk$N%zbwtYBK3javT}- z3t3iO9`gPz6xI>v(Nb9{;ixS%IUtXftCjWl5S$}}75XdWYLP(7<>S=K zG$B$$f?$hg#)%>tNgN|~JRR1rhFHgwX3z%A5J<2iiD=9$HAzHcL3nYZrt?W68e2jd z;u_P&*;y<}5JLhRDVDgB)Nv%RkzmqFGQAh;RbNIqD(}-_ohpluoH1Bt0xXkYbt=-OJ0hYsHxtxcU+7ol-F;^aQL0cArqk^bS z6ddhCZy{OtN35~-Qe(08B&~J3n205swnSpY%dMlg&@Y5i&^95>1UwoucNL zpomF<9JJljhlPf;)Qy@ZAafpt6frc>&Q_hWj3#P1hJAy+(B#-yKX)!RR$)(PI1m#$ zK25pxQ*IED4>IZ35X93_(j@QWc1lZP+4Aac=aZ(b!jDxWirj>GcVa`ne5I(FpS4}%a`R#L|6rd5X+X# zOkAo8YYP*`?$wvsF1NTZ;Ess4_q1-2a}YAl+vo3usTZCwf{ zmrpJEHpWWOZC@TLA$`YkU%$Fr0lEq6wS;)-%j5Yp6egZ;=JF5;@fENlmApG^dCbNb zD43FfW49D80iQV%Onw)s{3+cY*)7?+^Cg@7_NBzjuY_H#RK|FyLOHHv^QZL0OV+K> zek+tl0V7NBsnm{gJ_;?iayv@32x|SRideDxi&QGVR*_odTo5Mt@vB%VW0a2+wcVx3 zVL&-@8fB~cO2tVCB;guqYr3lwJYrUVyjAw%5Xo`$(bMX$rF7{ms-VuV>#kWIL!zb( z)e-%X)KE&DDx}F!?b(DC0(#A<;hs7vj$g0Xg8FWSaw4ok-2hd>TB=XitUIXLzeS}o zgLi~df~i4$iOQhr>J4cm_c7JRlw;~jeOEMPFo=;0g%dZmDs`MZfKqM5Ei>Bhs&sQ{ z1jr@hAQ{wEqdIX1jbcT0qx}Qe{@0y39^e8|Q%v&#(mPQgYk)|2@>JgdHqkL1dxmro zIO;x8!{B)TD7J6{QS~7AO@bJiYQu@~k)X&a4QvvPsG?Ah#`kDy;A~(JTQSj;{?Spk z=~ihFi+c^;+#E}Xl@5dOKvc&vr0wU>+izj zkMRqg1wDSGno@miQyJ}FIW5>rd3)(=6R)5D=K1fHy;b>UWw?BcP~OUyx87d9-tE5Ui=Tjn>-!rD$A|M{I^<36z{&x(b?9w$LFs5-e#e`jjwMDTXs(E ziezM7-gIfxY#^LbB3MhN+86bzth~!7E}eK~^kZF$MgRA%q6_WQEgz?-jGozNu1#E- zxL){1*^RQBhrZwYcJIQ`$AzQE_@l=bCePk6oO|C@be|$Es#0|Ndnqbg>8B4gaDU47 zQ(xGv+p|XfPO5rOiS`{s7TF70)O*VGx4q0-!`mNsmw6tPQ4syO@(a;Pj_R+|sK;2!&BW;wH)5hlN+*+H#7^ zWAsqpWe-LPg)tUTg>eE-%Zn!pU*r~ zy8eru5tHR&<%P<#MH673HfYhnLki5lkl z+@*8Hx@YCB?&qLN6W{k9Ol+|@eg93IzR`NHCqF$H$Hb>69$%uwZb~KTkwYm_xHw8D zgi)H8Xq3jf#5stRe=^;&QGb=5LF2GAU=Cs4+!OB=bih*@-vesp8Os3rgtCucHyZEF z2%*qlVsRCG5Z5T75G{}?+2!}j{o0q})2>yszV&(ZEfecox~`92K|B6ynon$bFSHE2 z$Dp?|6xEEMJPd`OKSSE$;LQ5gACZ#{7=~a&-Fo-0xDR2LA*KQM+sf^EjTOn z++PrMX&>X>B-q<9Lc|HEqs6*R{eVItF`kTe9^Wan}r=m_#H7{5bU`BRPD6(=sAI#I z0#^JwEKr>w8}>*nL6IfJLv_2P@bY`rCOYNe;?4p*i&7# z=X19x0qW`)HJ%ymAEn*ebKH*z8lS~S^{4wsC!w`OjEYNS1o3fJ<^e43az-HP1TSR- zKS6T;fM4kUhQSzt9XI~W`4@H zBwlFA-z^D9kVnU4i0cNb5jev)K^i*%9tl9L zxPJg^tB+z4T)q(~%fYKeThbs(j7mOu9nODU?Qa&xyuhrxFWI>h~>A?~LDocAQnmqOV8 zt{Dm(>&H4T#BD&3C2X2zC@t88d++CIfTlm-Eh}M!DBVCB3haFqwrfBd###-VVLbtcYi11B)K#frbV35} z(D9FEKmzB>rHMA_h*oVT0;m$Qf>4JR&vX!RosD~O^GBLm{u z(bSlrztSo)=B%B&+FShl4)=BMYTJ8oSD$ZJ+hJ1Wk2)ofoqK(IpycoG_U-bC%O)kJ z-$2>1Ja3Wh&&goS+?Xs9F-SclnsO47nV(Te7Msu()d$W(`Vk5dak9CdJm1Tqe$^L? zy@c6^xSu1q9aszfKd{8Oy}`MVTN6&LiOtkH!nV!@L+6K88*WyFt6G0%FT1m+cWO_> zWV={*p$^L}n|*5c&pjo)!9A^wWMyAIf9d>POZqFD!ce?M;Y%ZyI#yY zYM9H*J0Mfx9Tm(~0n7D$yrbzss@7_IkfL{+K2hn@P1AbJLM`cnxr8^D2<9^0T=u@j z88MhH>M!WO^6cE;tu4HL_>SRt#9*IUcfs=I)O%T~ywXK?Y#4~I`_f%=8+kJnUaRkA zBi(y0dA8VEuv4Y}VWGNhi~fg2Ee<%q;@h@?#hb{!O%3zB11B*;#|Or7dZUKUBZLNt zElZCL7LhSweiNHNs)%}$pP&YeCe|Gj{d$NavnN4tZIEE{TfTQi>dZ<43Yl@lL#@t& z81G6yHX|Hg(N{R^!{5~RsSE-n(?WLRBFX7pm?ZN zT2l_Qa%;+AK6`6=r6y3?P>GjbVe?7iKT??AdP}7~w57`8Wml-TR4zr#%Q2-S65g?X zB!`h0Y(f~H?veO*bwZSwRl?h9TWCQ8xVW)S6*Tz`Qs%o;`Z1?@C{9+Tg@m!FR-t6E z@mLXKa=ObSVmh0|qyK&Rc+K)6NLUS;{pJkS?vyunnV{S-_2Is$Xz3iZRhu@Cn;uOZ- zw1?JGZUY6ygH>xO$5d*)w13yuOZPjvn-y@IE|u7;+W&&M%AkF%@0P;K z?>NWG@@GkH#>4}E_Xu2mkCe`5uDcv_r1VT-wNWz=&`>j*7x>F zp}M;yoBWnnl0AncsL$XtHmljXA^V|yD?aU8b?&9K?v!I5mU5Qg{_IWPhUiNUb42!& zY@%9OLKxsU`RVRShM*lx5{>@`am`X(;#CRpf$ikyA$?z-jeJ(FB*#q767FU57{~HJ z$}9Fp5*6c7Qqz)px>WmR+~GH!1LR;|$|*t2L28dAG6aFib<&_h6=W{S8cg*S|9Ji!gOv zcbR#PnGTrRt~r<~T^E^Yf~oHM5`@zK6Th!8!LWF4H3YyjV2`cdaX|7tH~C$n+M5#5+G zRQj*x7$6jwD8;5@IyBYCa7{vhE>9T^1hFME7()_61$Fp({2mYomIRg{Aq=bIX-XU4 zzIex5LQ2rB^{G#*zNQ1}y&~#|H4FhMQ3KAOv)ox-x={8MR1@1rn%cG8caU!gjSf+@ zsQzQzGK^xLJ;iN{R>qD-#xcZhfC<%Z86Af{L}*)WJm?Qlrx0!5lHhv5bMW1YO>^tM zA9y=(t3%j!;M$=phXDDMX$roOU7@~atWe{KnpIT&7jp?kO|%&xYWCCB5V4ypVrNc5 zQRifE3^(yWfG(e!2}QGFvHQ-9OdN;GBRfcd08uxkhAV2uMsOulaJUZ|jsXr;3q|u* z^64YB#b{1qisHfhKCyX2Q3u1LD7mNe`%58oDk4-54!m&Rf)O=~7e29ziNyWm zKO($!37vuKRoZ~+&i42~vaQG-NPQF}545iU3JNZ+)h&Iwlg5TuC__1Sj(+Vz&h|z4{=s2<_L`KboRCFh%ylW8= z$4EKEJ%B&=F$L06pr|pyrPEqMHKV5*hZJx zF79A`D5}AdqSM61^Ityy^5?<^k6@_a4He&Zg$-*3!v@~4;d`frjeGcwdv3S!8xPz$ z^q6pHkUuoIFg(g18Vwu9WVNSXrCPsm`o*)K=rq>M4>O#LP$V=h8dRBCmpd+X{Dzr~ z@rTClj8A?7Wps6`=AQThfwXHnMOUW=*fj3bpct8?5nbusgi%(v7Qn2Rp*8IB; z*PX0#A*+$kY79Fz2#zhhW6Pbb`-QDX`K?D6di(gTePPGbD4xv*T@CE=rRt5Q2PuXu zl-7`@|HPp(*cjT<435l2hsv3`n5NQM7hS4+@3kkcJfT=nxplDPC|L4%OWs@tZz*A} zeBP2jhyBO$yVi`0k6(D43d@RFDP-02S@mH@gW%Z8JGO=$+n1LspUUN4Qm!nyTu$sU z#u0eOULe?uczaRUP%IcKc|+x0i$}1OBkSw+yrq`8Xe0Exleg47(4|ntbyRU3Dh};! z`xnlrHtRtTr^XqZ7Of!g4Po$tAK*ruTt$F=H^}7bOo)E{>lc@lDm5B+Q};? z!Y)u*m+2FW%3{Az|8nD^ zRb@>7*r6(31Efvw{;;t4^_*987`Bod^{%r-aMtq9+WB(c*$l*`r9Vi~7TG>gX+5@2 z3@S&~g)=XoyOwb!~pE_IV$d1 zRXG(vNN)b@$#729w|(>OKYfzV*)-kxK}LSm;YJ6_FP`qYn^QWYznfn+)Ba&u#p@?t zJ#p8afAx8xU@c#;Htb#}xHt0djdwj&f~Sf1G~I0HJ*~{Ok@svAJezsX=DXgq*UrBF z?5od)z4d~(nfEsT*9u`%7r&`%q5CMm>1f#dI0~Pa|3Ih9&H=IHdasnuslQ(Fz?xd_ z`b3pl==#K_a#!(LmEo-FMY}39?~`ToJRNbh$>nXZZ+$a<`@x@JHPqX0J z&U?1s^{yAZJ9zJoTkX8JgSocx-fg$ac<&z2XI}m%dR0NmmB3uf*N;Ci>Z)@ zrM-jnu50?Rwu+c&|oPQ2t4+%2{-2+xyP!d-bZ^vQIXs9QhZX zk6?el1$+Msj*5t*Sa4MFjw+B(^?t$8b#GD)vitbFT4i%x?E7+G*qZ-Z_PmBKtmm!u zQ|*z0!fWTRoTu*pdBI-6+bhC`O2JUa8|v;_as^8nZz;PDV=Z$P^OoYd3C5f}g0-5r zRtwg(ymjruy4J9@?Z@Ln`w-tgB(#t4?IQ~(&V<{~^47B~CiPFjQpsB?iA+ylK6mL{ z*ik4r%6UimxAj6rGhfks)5llr_;H!gdYErLym0hMzV*ql<0&)`*qJfaXaYja85b&E zu6mGW^rCwkbM$D8CEYs%ng{>vR_SUY?wo6;D<;O)Ube)v-Fm-WLTwh#YYZDU2!<`Z zVar{M%XV$Ys!`CYW8cdmnX)Gj!@u!<fsHhpVtuRuF*_pwvo*8T^79l zN4)6DtW&}!wn9zSfb21XYjf?@4v|Pgltr{$1 zJ_KxuIv%Y^U&QahR9buysiv684~}TkQja}KhgA|}k(ir_50v)QFIFkavsiYbQWkjHoo`m!tHEvvuj>#S6ycJ}JigHJ@m^1SB zNVd*E3ttS|kzIl-qVwM1VkzEhSNO!cQKN$h+EsWecwR1RT`hcNo} zIEHEpY1WB{PzxcH`4c6%SG*+XUHdap8k16}5zT(!m-Zfl=AdbR6W=LS4%;2yA(e|Ql~}6j0W}nwV}HtRtPG8p13FQDi_#Yrl;bVQ*HUZ1PPU)9)XL{6QOU4AF` z4Cn?tgk#C0o|IqremOnK)J84@xlk9Zw@3UiM4a6Fe+KC7TpedMk0oguE5@ zw-vdgQ%W!Sy^K3%`ZIsiQ4gHc73ZAbchJ0WDUJg!@KDT~*jSSlbYW-8?RSEQrtCS3 zSlRw8MIMwbm7UU+I0oAh0v386lQ8Ia#lz)*JLjSd3N3_9j9(%z+Er6%g(a!eKA5Nb zsFc3^UJ?qk0fc%&_LXc2JOL?rDU?Ev-MvzH`5nybA=x{75_pzkS)@Aq^Bz5YN$WJ2 zk2p`qGD>(!_3O_Ber0Z}7*@(3V^Ajc84G-=v|rb609I%R%~gYulzs=){(Rbf^yjWn z&Vhsi#H`(c=>X34ha%Z{{CH|avZlM02VX0RB5`S?G3g*ut z4EfTf84-==wD*ZrvRFN_i^_CE9>&X#ODRj`Qsg4>P)b}G`W1T;cF{3INq&0d_$QM6 z9!*D}JH9I{$Ct->sqD!dYPG%NVCh5oe7w|B`oAe|Rfgs*T&axOa}||UDl)B-DjlO^4phy0UV+_wT>-RV8@I zByArrNd|ksib<3}t08r7@boxcNZ*ZExYjpSx}q3k=y-`yUWEQ~Hun{CYxmTcHcztT;U~J{e+K1?<{F1{YHn z8xYq>Q#P@!>{IyUQ+h)EV~xB_=OY8TK3EGQ@n$*_wbAgIFrh&Q@+N#{x&+t3ba72o zZTQfs_LTmC5Hvc}XF~w0Vmcg#Eu;9Nz)=6TsHlw+Ljv}G=~Bi7{f$_#<47z-MVeAw z_06TtZRHEs356Tx58cVz$h82D!)Kgy1_|+&aXM z+VE+kz-Xv8Mm8MDn&~!Gk%(e985@oT@C6gP*=j2#MYPHNiqg}K(WgzKI(C^ByV#NL zgqet)%j7gv!j~wZ7F7;$268jK16-Xfb>a|Hr-+(l2)>|1U!Z~x3Vjv@A3>3?bB|iF zf6l%*#V!z!T0u|Z$EetS;+RW`DjLwI7(atMn02`23rZA4R0gZ`ZL~i6U=gRL!rY<= zm&qU!jye)EW(2CGuSHc>Gk%ez1;Kdk3gFePZBTR6Q2ejIEEFV!ovGV2#o?cLvGbp(Kk_W2zF^){3c0+_h%Pdvsd9u#vYmvTI)4 zIqcjo?b3w}1%jcJHQ{#g)BR2c739g^&SahnO z_F&HA&3R#SfnYA?&BbAJ>D11DGTS3=&-HZPzGlI&<{z|HTp3DA6cNLI?8jy4rw(9S zSiChSVps=|Q$Mxuo=Mf%98T~4&d%#TVauVPZ8?PfxtV$)qvB3R#hW{)Iv3Kru{$@_ z`9W&>e>YS`;#>VQ8`-r+k=|BV<)#4>_{F@vYjoYF}xaO}S>fViVleyt_K=uDRo^y)_gmD8_|6 z^zU>>3QNGN`N9p61Kq-bA^gwvyx#X}-@H#~?BE+a?v!>eoCw}aGiI5lbwH>U3K2|? zq*y3w;ENi%DpKhMfEx54DTXDCv>iT*K7%ts7qnkMiEsBV@=yk)ZhU;nHG6Qn# zk~Ly*iFYC4>adzuYv$9Lw#;VUu=(aL!O(iIPUUcZ+@MN#%>)HkCGV;XyQ;%!YoJqZ6`RwYjWsP8I<}J;4HgyP_{QM?=*m49-YcgRo z-ejk{2rr(yaPH!B7oMAaB8+WtW90+0J{Lr*&(u%tht5c0t>A6sy^Ue-M!~z8_in!9 z-5R!Sd)K>lcHQ(d7bh-E%pCgi8Q!~fYWIR|+lLn0)ULbs?CF$qT$W+^7+%cJWoaW-NDe%D=6Yv**pTZZ{&!6Cncibwxlecr>XzxsWq@-%PeP(Oe zQg|;-l~pAxFIm#>WR!+2Wr76?Hqgl_5*%xwoP#tR^=MI}h2wX!pqjLi&)O(t zZQ--FgtNAVZQI|`{XloC|3?O>?&PDFYjHP%L1oDonG-Zuj7~JYCqJeiv~o5q)3v-~ z?fizfHov*~yU={v0bLKP4di9ddRXR09kCc4REA7)=4SBAf3c~ycc@>Pob3_vn)$ru zJ6W4n7IPzGy z{js|_1#_0`&+*WP%x~pOcicj=?7p2A&hMN)@S(wa(Q?5O$@WBY zJ=Zo|*+8$YSGL~Gt-jt8&aI7&|1+|f)weuN#QRBS~6UwbJ8O`@b)G9~L zVygx!c`tQ*p@YxZc*{A}!E4(;bh_vK;uR71AJbwO`1Jl-RfbOuodlQZ)B8}D+oQg} z4L$AC`!CQbbD|e^*$1|%|0+#AP^kT@^cFW9KdEvKlxW}4ILJ(I zsRrowR`o!o_VzXx>_2s;53JGtv}|t~9AS%kV4XH>Eg}25tbqpY9YYJH__JJQ*0$Hd zaW_vr&)*3i|VO9@p(f`8IMv47>iJr_g%xqxh7BwK;*HN~1QN5SMrSyeZx??D6 zkiHEzs0UbjXn*-d{2mM#r9FN5S{->8Uy=cI)AT#(>A|EyeB|&-7Z6HmD2B&cKlzcu zORIaOyUh}EB<+Nx2eKOlXrep}`@1QUJYxv+U|iLUJ;CHBAs0uoALI|>tr(a;(SsD1 zUa0)A6d^%OewN;gh7{wsX0R)oAC{ssIX%=Xxr`dA*8S@rq1Lo2NqUs7^YLl?8XK08 z^>O_NsSQyFo#K9mzMIs=lb!^8E95MtDZL?ATzcg*?a3D;B?)I^4Afn?vR}}EJq#l| zbDFXTc*H)iV!0Fg2(6yjBb7&yg5sgNOQi7f8~bS_7fE^&=~NL*a+*?OCZw?18KZbf zYZ4;Ftg`Nk6lxiMacaH0Zh1@zzd?%+*J#Bx46L|(U9o=3z0$;TNI(&6rl}@&>hR6kOZdU3h?3B9-1R) z$Nq8K@^C%7B$zLMEi^ckG1{m08Q`PPXW&EUNfmevd{)qPfR7n*3{uYWTf%QYa#9YV zR0hb8RY=o<80~Uek_|pq`0SC~@_U(>%7(CZgzb>SNVa&%(CR(=C8%;(iN0gyav)Tf z6hnR^uQd6@tUrFD6!3JvWrh4K-6XM0dXndn*}{~rR!H(SU0-9Sc$L2< z2zfq}+81=SN7bcFpv^?9kf}PQqkA#26dkt-Ri?7TR8j39B#=?<2n2b!d_Q(1Dr!E( zji2ftX5UZ4StxckDQXIw9UPq;qDv&%nJ)1(785?xL@@blGel$ZcXYTvASaXc&615t zNr_m*uNYq;+aHqgG#UM5Fo7%0HpNfO5HGE)Px$hHEYGxvXM4m;kx3o~X+Ws-c zXPL?85D@k3=H?m6-{!H62! zDKmCzO0+Tri&lnZtAyp)*Kre2Eq;VLQ^qrHYK9CApDVysoJ^eiRVt~E3_lsv4->xV z)t&Up3&)k#UVe&XF-s#+zis<(S+t1i?8# zNERg_sg@iJ5%i^MkXTfGjD0{Tl!mB@l0s$|#f293g$0o?L)=%9=(q-Q!oN%r<%AzQ z*|!y9qK#KPSC0w#>-hY2;k5PN+xKJFJ9$6I3pX4H8xJafS|My~Sd4wo0GIs5zGu)b z;K+fecD@t`Cc=)UX&t0w$$r(m2WNP;k)JEUZ$0l>KR?cUc7+}7_!uxCryv2|J(I}A1(pieP;`^XzTH+sVP&Ebqqf^`#4BiNi5gBOBf zTfSf`;%!A?TglXJQIcYHPOCq}I=tgb2hQn;>+oM#(=Yg86*B7hjJo-CZ#BHxfHh~> z+Vl%+=7pnoTor0!$#0x| zkgBnEs%H*e?zz+hF&REaar2q)Kl}Exk?b70WBX>B(A>#4cjCCfmOPdQ^{pe<>dqI?|Nz$jpR0|Hf((Bskfe*?I&D=R^H8DciZ{vMKgI?RGvH` zxBA`O>P0KLZK?vVNKQij_W37%y=W(>L)E-VXxfYatGnh3g`#@CsQ!+p;r7I@7t_c; zU6o%Tod4odZEICSnA0GhkGYfAd z!`J$*^xes-x!LgR#cWD3N2*dTxjbU2Q1Hb(a_7tL0=VyaRXJsgh2&GDs;Yj&{f7H$ z%52NEJy-Tzwa!2D>&0U7D#2+lp`iZVg8Ic$a+j%!ON654cZ-@A%gJ4#s;PZr-y8d0 z3C*s%w&lu}J6RPs2Y$U+Nq$wze%0{%MCF3Sqf%wdTs*2)S<Flo3|8z|Yz5ZIYy9ls%OVnhRGqXwu`;yZFG&^CEg?|CFkdB-yOpCC7j$i1Dkm8ek zp|8>@g4iX$7z30)^bsH5Q4-}4&{D@-pIs%Kn6qAzIM7wXO7mY|s$z)^ayv8s0aKp z=TCkxgEl@wOoiML(~Ou3KCyCPoeBR$iB&A~ccg{Eo%+$Pmef^iZbc2T*S^40Tg?$5-*Pug}tKWgnv{m0^-i{&b9&wD-#;xcrtV zCVBnL!dJ@SB%7R~LW~rp^5HuJ3e@Yf#;$AL03DSAHrut)bVwYTScVIg&-S)8b zvgY_b3<>u&N3{c!qbJ!k`Fvd~tlro}1aEfzfVba!YCJSTC-c1nwAbDzeqbIOtv9q$ z^>9>k62GxgY$#7~=c^Kf(Drrws_5Z0-jn!^jjo}~q7hb{pRA)Iy+%X~JBL zcxduu?=gbO6FX8Pl|LDYk7E6I04g)ZM4-WxI8S6ziGJnGaCm=$8#%=tB&eJnbq7(| zU};X`wExe^_boDR!dUh#9(D_M(zkf5v2XE+_~pKVdEVPJ&$|JObVsyBNX_C?vla>( z!l@0ho1*uHZTlAt`!VVp;GM^(=FPdnspS!ShF~wo|J;O7zL_uIeDj%b`Hs8k1#>A2 zrANZ)k1bdp!V>`y0#!hngU7%y4*oG9G zCA_l)o3es?4ewqfLp)x=!zO^oFtNMz4EsjO3I2%_3&C^YC(hk!5ZaEs+jeA8rP`rs z(L@@Wgod4b!_M30aD#8QM95kDZqC|;#|IWY{q77f{O8$01?CV8G!X}TP_0ak7bob@TOOk}n0in%ZW3405+-4+Im~xzH=o4W0ap=ZW&~#~cJ9*GQ?$?*#cJ z^1~>cs&r}(=LGfLYvovyP0E95>efq8<**X|FgMW!jXv7FNL8ZTc$z~fvtkIy+07wM zohWC|!m9r-9!bt_hAbO+AWhhy1-IME@=?s=dN#&OZ}I6xEC;PUa-Eb<6=DdYCuqaX zh~ilU>^C0$$Y)46iC_zwd`8I5O@n(`jhe(biDtac@QbH}(KaFfRHgj@38#o7=jeRY zSSXh%XqRt5O^B!O-jY~8(Jtk7^d%)4k#%2|K_RVp2=r>`Et^PJnr5vy z-KSGLqqf3lN)=~-sogu4m;O;}=chBVwF>duB$%?lgdMu$EnYkHDS3CqZ<4ja*$h2x zg>Y#;i?mv!a6P-_oPAcwCmt8$AWF1H3M0Rz5VX_ew<9Ivzd0qWg-{FCK58qR3|89Z z0Cb;R2FZro?c(SCAeYwA{d&|B!S&LYa6N^w$!AaCdPmUPa3WD@B*OVS`99Ma*OSlE z`JIZi692s4GQLh{)M#3p^!OaURH+RTxF_<1l5%R$)k6|i_W0xH;euK6Su$GDC)`s@ z^zZU#iKmu8|Lz_V&L+ptMvYPP|Qe|9iOu|H=h?XAQqHpibG@4dMwgD2tMSn9!w zu_fy2ryAt?-PD7Fo}M15jJ`D7!KjSEbon%}v z;FO{g|AJ38e0t?nBpZBk;PaH^mf!Hnh0oJ6M6xBC6j zE##h$+%b+PKkz9~>L-AE{n;=J6S%b^^-1|4Y>__)W^uyV!T1?Yz)SpYn5BMCycSD& zvS%r;Mc6Wb9?WuoexDkxr?8I;s1k>(sMYLB+`Sw`Ji-Se1pK6uL06M6_Onk@kwBb_= zALZUy2cHSWIBVgv+AcP6Tg4DcWr+2$b%=3VF}ED}ti%P^Y~x^E>rZ5N%vKcBJ7^ZaRQj)uZg!|o{xb1kY<rqDM2?eh**2N2oviH#`4s&rkO()E{Pb zVv=jF9--S0bAOGn>)@U)(nl znp0|UhLL<=gnNyGv`{J2#7mdip+%4a4r49Jn3|;?zXz=_idA0Lv^ejNe*3Tssbkkq zLn=Yz;;@?SG-$YQBkL~ipO74`(f*%!MKyyXAtImHmDAiu1k6*UP3bP`wbZP62*)m% z&Oh;P+sMuCYp1WAUMSlr6f`XqGy(GI11kLsc?D{2KQjGyuGgQ%>&3kZ>#`bg-vx9X zm7C^0tlZv_cUzC&Y<=DJs_VKWL=40u=vxN)^c{xUh4&1?Mp? zpO_u^>WLY3Bq#S;>6Ow2Z~c7tdpS)rt@I(!)0a*!LDNHy zcCeVr6Yx|M@WtXjO9{|y3g?4Kr>+m9#c_@%QLKPV7!&aD+v4;AluYLI zl{&FRKOve-9qeZps7nQ64F+BiyBc2B@q2|}B>G9(EERPDpC*s5!>BZf8gT;zAZE*b zpR&QWXy8+QyfDlWU%O{XU z?9(C@a{rBjvW)(SVlw3aG1+dBL0h;uVS`i9fgyS1X5+MY;5%JBZws=ebHd`O;gqrd zvr$VT-EoH~m*bQoQzLAkWHZ@u>t%BQAA&|xL7V*(*r>iv5#!VtO~XalM^LaHKzYPTYw0QO15Uj*#&f8A}O_qt+EE>o(bbO2$D7m(+$#mGf!x z=qH1<8*8DhG#5hwdqQ328oX3An+s|w}r!^6q@pCx8OiG5+mJchdh0139eV0v#Pzi9fv$I$02 zey#C(`Wv}7a>K=q^PdxT9pQH!31>bwZTg^c{XF;9vu`~+eSUWDyY|xC+G*{Fh77@w z!y9rU>CVfgmr5gf-fKs$9ElVZT|0Z_>|J->>;Suw-dz=Odf_SL)$)0@cfF1CgTjX0 z{D$3O?;e0Z*s%TTN^GNZyo-itm>6rvn6xtSGwl4cQ{{?0 z5qwYK|H|hV0w?Y^ZNHUr>v5s!0N-?g)uvB4+&BC9^%CT01`O>#4-mJLU8uo4%a<&7}u2S&~ z_d?ZHsI9nK1Xug5L(}HFxmDL2!@27g4DJuwy592*&gCxDZGX3L`@+cBf+7E|XU%ng z*i*k?$i@``f~$si)d;Ru+(JHW{zq@kw*&L7LPI;>&>pVceLHvI$Z*(ue8G?x@puvA z19RcrfKb+i|A@D7p{a}ab}d-)&}N+!I6B%C+0V95DrtTSsfQF)4J)&2wkJ!aD5VqMe^!|yr%g^#EUonqRQzpMJ$e)BBFuC6u3ULSk;jx~@0`l>ytknx^wk#Hsqex{fU#Qr$ zSWJ!*6>f=;>GOU$fS;(GpwHcEBG8YYSF6(9v-)e6D;B=0<=wPa-0g4G-?ymJ+tkx7 zi$+z3^K!|h5?qS#+^f$)Y5UCEXJV(GZ>qm~HxwMNrD z6UD}79q{cD6;k`yqjKiVmQe=*k9#*`CwMAuT*syHyVTQd(7VrYT`s#+c0J_{+YQ^T z`gb<}U~?oV?^^AZ+M6lgx4muqn7&$Z51&myhr%Z+%!?{SUv%VH^bvz)`q;&h3nPmu zaD8a7&FC+iFPRs0aDg6-u9?%~-4ctb1To-N{Mijc?m9kq-J+2oCY8~1(dQ1nJVuTjWryP30? zM&NYhce7pC(vAPCjdLz6fce6WcXFGcl(m>aA#iGevE6#!Q@@x=5Em5PUm2e}7IxJv zW)Z-R{PB5{>-un3-C{ODauRY|%q55?5t2uce3j8LPmCg#;;58S`dw zN@63Qzj3jcASJ4z3ZW2JA#IIp+bL}GO#JnUL?zn-#C<>>T_>s5Qy9cmeHf3Fr}=BM`?RIYaQ z{SLJ%cL%h?kE=mCpWaVLTYnm&dF6{cHxCx7f4ogSXwm+7d+T;M-r43HwCiuX^kjPW z*Z^9nQV%-y3)Oqb|NW|#oWU&Z&$Zcb{5-EkJLu8gEo@%{*FV_xWM-LQ4w4i_dT62Z zB7P4VAiIyrgA`ZuJy?8*kF1d-t?Ii;o{{v(p_DYam+37j>>NPLRss4*dP^h$Q8-Ud z3nF@gxS6~{w5piil0D{t9L5*lH4mtNpyu+u{tXIB-c?P6zV~XLH3}uknJZw18J-t3OjFrfft_}j7~{!*1=0e=SN{B5fG_i4e37 zPkIJ^m(@bS2jx+KvRYuBy8;y$m1D)IKp{N^N>>Pldc^y%ay|Zk_TB|Nj^n-)oB;;& ze&PKF2JbgOJPGhA03=9&Bq(AqBt?j#KmsHoLIY}sq(s<~U?o|Du`VKQIRtI_2(FnB zN}Ls(&l@p!&S#R6EGjQgt6}vRL1@LROX9oBtw1`CrR3YY-@p1fg9b@?wfF6}-!?GS z)!kLqRn=AX|JVO1=~7_KL-Rq?>fHsaq<1pi3-k^&6_Uo4InAyxqQnDveR5nqvp!4G zdm^toOsbG}k?cU9_@t=jB(JW-SqxhCKckn1^)`(&{dL6^ut?)Wr~DP;Lnn>j&8!fk z$A>E9str}iG5yxd^_I_6e{ByUSFv1zdX`ojohRrWox2ZMZ9Mw9q;aNGp3XSaNriT3 zzkRh@e>o%vihdCC5^XK+I9$rs7M&-`Ji+>`pH~`H%F2vXZE?lp@-T>v z`cI^hEtap7Q!1b6ne0&gkqkGZ%*25X`H6Z~<Tk!%9>_&mVY6<@2jzoFh z_~4n~F2(^&7dhOa%OGq`ruM@vrtB@(cQiEdw69{)T;V%4;j36QR!pW2f|C&cJg~81>c*$T@Q9RLmH8Qnm4uFxD zU!zi0TKpaqq~WSy8JB3lLliSpz^J$TDF%TniQue9>irXXT7+QH^wh*ykf|Xp<}tID z;o}psP{1J=DFv^B+`@)I;r|t7`W^*3G-0q5L<7c)7BcU91`dH~yvpQGk+A6!dqtvq zhwh<$zlD85cssTjjI;^%QwG^qlt`PB^R>N~AG`F}Tosq!{Jp(GYY*4jbG!6M4evE@ zt&j261I(Huj4U$3qWJ-Y#Sh)ysclh<WErBuei^- zBiXfcOuu|?*Y%7Wrgv>OZP&kWyC{;mbHTcccynVH#x71=nBX%irp+%qP@Jf9#aT+` zOrmc#+SvAU8~XXmer=&b-Da+CbENi>8|E9Qgl+q{ZTliU`y=}wi)0>HuntA@i-i0- zF28Qtya0+Gm7B}ae?_#YOn^<#qE>oRpnaoGo*d4C|MIyOp=J{Yz90PJ?c=)k@iqID zy0p$8720-iZ95{ZJ8y4^>X~YW?}o2-1aB=`X`lAo9BCkmYrP7&PemF+j}E}`-Q=$xWT6){u7ZCXm>wwHI*L`%!2dw%RJf-;%lD&au9o1w;vHx6uB)?^T z-+b@&!5gJQ*Iuq`Z=`cyBzOOU^ReiT-NKGT+>S$$!$bhg{L%%>KtC!)om9jxVw*HT9mYI``Yi|Cb7->-P`<;gEi;K{g_ZD~5YJbIi&iwL=u;ducExg=usbh8@ zpIZkC{Kah-wk=ysUi&W$CL7sLaJXKnI#>0T8o`tvF{R(JxR>13a~+WlJ8uvE=&|=6 zi|l)X-*qUm^U27%VcvaMupW+B4->7kbb14vu-<*%h^fDP#t0jTe`h`XNq8-motHWb z2D6Meea69L)6Jqi>u|y68VA!%d|oZ$3s&P`hH1fOqj;K;!VC&y)a!^y!AKp%{+CD{ zpO12#LQv5I=?_KfF#g)44pPxlHlk>ubjV1;3KcD7n-ojx013jRAqXVsktyUdblo78`s*LXCWE$LVjO|Yt7ZC%ytAPe~R}BVsrx6#o zij7@$rduU8ir3Md$qcRHXv4_qljCD#Eoae6=Jv^^P7FI)-E9jNVt|ZQ0hgPl%6CFDOT}l(}f}0b9 z<{_Fu?9i`!fM|BQ)D}Hjg#L~jcd^ThKX4a2l=uU8<&B@_5$@{lruBBvNUOU8*4y=> z%{pJ&p9C8a7A&gR<;5QXi#+n!-b`K(HOb^#SR!M-F#8fZGX)ZRkAog<)qkHxb5mDa z6y5^cH7}QH1o*u*KLVr%=zhyG6Z{W4PNR$JI0L>b1+J+b+v0?b2C!q;}a?X%|T;v165X>Do?& zmAsvDKJ|>d{pyt*!`(;axO&E2o!0uTtF+6j=9FVnyBw>u%P*%}rCp8z+Ihzv(AuFb zZQOyow4aSTa93xFyUO_#y=_$cg0g znK+y(2}eaQI)DyM43qrV;j@#;TV&)&CJ#+oDtwi_Th{p&ja8xl2Xz%~!8F99qN>oM zBJcPRLWm-D=OJi1H0U)%93t3|J;2l9ffnGQH=s{fn6wy6~ zMLWgs^Gj-fEGoTdod}H`8w(DPv$jB7k48_3qya;Jg8(wq5c&-LBcfqi>17HKokhIc zt01H@=50uTkQ-s%i3k|x?TsY_>9}Nwl~`LZ-(Hh zb5TEvtF84ph;tRXJAiy=2BZparrh0%=Mi^nebLO@y*0F26~7 z5L3_OLe$fn^~#GczX*{(i4gGxxya0%i!WSwA)1zXamNMNR1~kIQ&!$(%Oy)pVctTB zp@?1l^5$qtChMH+>bdqeHom@buHky-jjVTbZ|2@`-QE+)+#U6%U9?=VfV|-}gS?Ss zSwY^AikRJct?x=7=V*wS8t#rIV9?#&WJpS1K2BtePr`(zz0~d8-DZ5tZQNaNddt(j z1{XivY~0;wde^Ysi;Ew5jk{Y+KT0m3c%zZR778b`Vs?KTorjoRA92CiuJ1#TBSi9s zYAt<#!T_dZ`~eGOn4fgSZ|OlcNPK|L0fl=ZM#JC zehU5a7@za-YI#%(eEPZNZ^P^+R-j+i_9Qad5}h3@aYFS=!xJ;4PNXjaD~-A>MbGHp zw`#2fB8|?sAIg{edx!uXJCM>RMKve6W%4;-StW0=CX*DE^T=nsL2r+0)Z3%_Egcqk zDo7kqxZrB}0gZ1;{S+Eaa+2H7wOfQRD~m${eaC18^9ftF`97d_@;_YC0{Ec#R4DJ-}{_{eKJW7PQs=_4ws6 zEY~ffqLXR8ipxG#w^lTGXbzCA!HUV&3g1|mJydl%M66g{N-M%ZghdyAKE@iX2>o9` zc%b#e(9$^M>;$?;#a$=Oog=_LeV-=yDntKDXcJ+XlUg^C!;}E5L)sr! zRM;O;K|*=AM|V}Uu#GN%OfMjXokbV*$f1cdqairy8x^%f2t5oFK|@6cMPx7?ZI$(4 z)cy@6VbRzOz!XkJgfPNifHhb+bM}pf*Bh?GGu$S@wFyR_Atr9WU?*7+?KE`o<0U?$GANKr|k*7bSm#lxCeEx9Ta!%NC5hC*>=eWe@)=`oV{>%+WCdINl$ban!acG!xw*t~%h^ht-VY+eI zW3`*7x*(4$VUkV1+4rHzOtuaQ0An7Q&5wV#@l7>EI_};%~o{(_3$R&DdLP`c`KRqPMKZ-ZIlITaObL zx6_Tim8RPnSro4~_Ewv2H~0{L&tvR`cPDQu#mkHoREMpdmQx5C&z?B%k$oJem@8K&eQw zmh;Q!_(hy43*QeRiWikt5ePj|SsjGv@Lw?GMdt}nF`bs{649R(%|!z&>Xai1$eq@E z#?YtVMh$d{IU0pyAVL6v4_zaM=IPYszoJCj{&TFhb*Qab&&|^+wNpf( zsDdeAgJ6$FPfsuvUEI;Y(r8o<85cbbJmy zm=v(1A4vdPrdQ&HjQkrfF z?3y|&grnEKGPU92b}0Z+79T{7f-AY(SJHQ<(A54^k0gm$8{J>l8~a#6XaD^ zG}KexrOCzNEalaCg1pM8Rkw}98!>+q_1u6BVP=a5da+)Pt7pG$Ko0>|#u$)(Wt?bo z+2fG@X7!mIyART@Ya;)my6`_I1#^9k#@HI z_|H@T!`vCt?h2WKM8`?)E!u#^h@Q1Y+U+tji2?$IzArjRicfN)z33ph0r`T-_d!~c zA@-uQAG~UZNvK3c?hzC5`>Ys|KaF{YkO!5TFagEqB@`d3W1u?1Ycue9&?$MVkCA;} z&08p_7YaH!ID1K6$J^F3CpAbQkoVZ)*-_3@!`o`%g*6lCyeZi{)eFrvd#Ye7<7{QT ztzxR{j>9FP^W?WRV%;1k1_)`lYql%4xq&whzJ5@s+s@T(hg$6UmWv%Qa@E(={6+Wk8{#i>v6ueNPVW zsfv0tFS;+dXN$=#v51LBidrMB+i&OIc0~e@MGlNc9y=N-JhtFH9?d9tnMo)<|E1?S zi|=Xxu8-Hhz8-FmuebbQd9c3g7sDW#BflU1NM%H?n5iZ@O>C1EtJ zTi!@^U8HWyjeR$IZx1q|v_ss$p~#*mBg0Qeo(x7ZCl;(HrTqfclrLDrXEaVHNFt8e zoCW`7-zl3<5Y{}xt$Bp6?z}N5Y~9Cg-N#q&W3>{=wR}ny>Lm(Ou6TdvjfxxDx62}r z4hoMR<{mv9*?lB(WF(R~x?nxJlv{kYnIz9TxQY(GWF4QoemY?RIU;NUGoTZpHMxIL^+;qjto@HGezcBu5$)7d7vElU%*Ha>!{Y;GUAU6ot#H0M+ z(TM+8WaDw(JtkP8x;v&3L<<-(_?9uZNf2#J7DTJ;Z#3SlHuhJWZr1GBgp0qbGWOS* z{;GN{;(uLa>~Aprb+L`&bw&ysD2x$AQ#KdVm?)pCAliR#n+p<7yWi#_-hQ|Oiess* z(B@MBEmkGzfK-gGJwX{#GcSp{DfG4&*hD0P`kSDK^l0doNk9nA1mb`!=mF!T7+{>B z=RY){Xl2E3#;^fNxSt2rmFZQabN_%JwCe`|UyI)=iRt|aV7CLm68zkZqGpqryH*U! zMa${niQvSu!B4FNqfdkKGvZqbA4B33MIt`=*MN@XZ!? zWFT%aMcmY5^M2^)1Pr~v(b}RF5S)p`g|Ty&0hk23#2V{Rr_xd6w+VE405l~@!T;Yt zS9UHL;)pJ_+qS?yXoJeNvN-~XI61+X% z^_4oolnGJVI4MMsX(AOJkxm#acp?%w6xsYFZyy#+!x7W4y6P=g(4yyq^Rb_p(tlza z{v=Fv$d@`RwRYyj_m1PWlp}Ouy{WJ`iAw`-uKujg{Rs1oeEWax2 z2Pt)yQ+YQfpOH$3IfT1e@$bNXM^|~41jewWCh9JN%B}{Bo_;SYG>9Re{6@KM1Ii9t zeu6$yQt9nBQA3^HVJt&Tc{=2>S9~XOYh59x(T4JO0wu_*e5(T17dri>p$lR3_zW5) zN~`>4fFO~zM7xW)BaU&Tz7eAXLVUGx$#IF&K>LA!lkV5X69>_TAk1U|&?#M+-ybi` z^m#Col&>D&%jgkh-wI}uThpfxXyJX?OCC@$blRq{mpqKU^e%wlKjU{0KQNs73m8E3 z_KSR^itgcHaN?A2cmz;T+?o!DePgG>J^)Ccx`GW8Ys0SHGVSXf-0mCL+8r41_wL>5 z8x9_U!_tXR*w^XzRmP;GT>h;)w{{QuMExuUNR8BD<4U#;6_*PR#iftYn@$Ky0)jOzZXH~+YP3dJg6BaF3j!brRj*I|V@tryj_M|$D ze9umthWeuD8O%5AJ2DnJa(a9?L?3)KI5HYMayIlUtXZK!%-P8l6awr_lO1}Nn>Lv>qLWb|#60VR6%P8^d&Yih=_a z(0V9haRhQgB%&Tq5(!YXi|7GE`52COoh~21i-f7TvqxZzm)YiILTRBTD(Wv0EZP}& za(wvk=(sLJ(v8+?rb4Iyt-T3Y5dJ@rdsR;4hqA8T%vs)2DG?j`c=wKobq9Edz}f7V zz^8P63V+%SJhcM`YhcuiY5s|tUs7?8^X`KY z>%os~SzGPl8ZqtB~l^g-MUf>p2Wz1s85CxrY) zF2C`69qzw=j-!{bwZ8Y~nX#F`)G&FvIwH@;uH5f;QEHTxp_4!~fJFf_ppO+*fy zjGTNX68d7~ndc+VzYxiMaV4@EuO&vPYW2D5^DB6YA3QmJdxNm!@%MK;9toX}^qq;c zJ{j)C@4&h`#47`NAe!DvVH zW`%Kkz3FCUcPcL4UT@sqXnK1?S2ix*O*d|DHocpXMe%y$_Eyuo4ONKW&N6OaYr36H z8Nh|4u#tt$ENo@rS_)O3qdGaka}0-w=NQVu;ETa{&8Qy3cxN#_C8EIR!*Nvbp4GL} zWDG<;$3thvB~-9p)KoeY94MYkNW?>S#NyLzi>@)?wP6@xc1 zqdhtp?qTq8^sylgc81>fi6%c3J{im%lNlf#tWM$$=sZDS0q9h_OptMdeTf%Aw5 zaAQT;N4^&$L>?pF23%UD=L3IMMWV3RpyfO606jlxH4#Acg4u79FePJuBJiR{{6t#4 zMI;t{n&wwFa4M0$XK*n?18vAKs6uLIHy*s_Og=R8XXxGw4E-LeznegEIK3$&3fLUH%?Q> zshB53wh(v{tOTJ{>cHJ9G;Fz;QA8oMu@>LwrOt${ZsSdpajV62GpRcj7jI`7x7tl_ zXO~eNn}4!kW<&fPyOBaCg)tOSW%2u6boS>;5&b+CKcZqPlNl|R4rTF+r(&+M_<>rf zvy_1hkf28+eE^k^P_wv0u21~pjjdI>oD@{TRb_rL;Z6P|i9Yy6Om*@n4DAw$K4_Nc zgFZ}*#T0d>V2Uvcp=1}s@i3G7-%y_rRZ2tWGmCRrrSlczMIy47iCCD%h`6~Dsd>o~ zHBYTcr~>ISpwk$B18L*Qc%R#x^`;2k63$yP)hkV*`w`qA7uD9s+jj`29TC$Gbs{aN z(?o(Eut;XxA(Pn>x@|OaZdH zR-%!@Bo%2W20|st!5~h9POF3O@fwXru0%@;A^j4CwtJ!qRoJH{hV^^!`R$(f|n~fB!h{qjF5@{L)gGk*@}Fr--Li7ryCpxG$Ri;oj`NJy>D6Ckz9 z{6}6zU`H)L%B%APc@@qk^6H`XnEm=skXKn@kV0pEJ|q&KsK*&x9p?I>Um?x>pJCQB zlNhf{CNU=6ehfzyVU|FrO9MLDGN6YStICnjqw7=+Nwz9LO|lvzI1UjGeV0{AAUq%xv~dM(-#w}#8DnKp?+0*W0bohP#G8nSHE%K2I;4_P*n@n7*h>=hs`Xe`obpm3qV zJz!kU!*U~=<4D+2LjZV71tGK9xOlt3xTVVUcA<^pRYrvRs4Gba{|}40CJZQ?CUwF- z07caS$PYDL9>;Vk#?MKBGE|E}S!H{enW5m5fQW1ssK8L20EDM7EK)=w9V4UTqw@G~ zz^e|>_@9=?zf;m${T#->I0Vmc`AUOe%8r<_zp-j7h>47gA43c9geNLdV`5IEtLOHkIxKvZ=fvA9) zDHtjx>473ib{^w_4*6X``C{8hoIxEVQ6A-5&LDH0bp{U9_p5ZFT`x!`G#&(6V%~~v zT7qJ2=|5tgY?bFqm1(j_G^HqgUYT8``SMWV{{fmbSmjc2PI!3KTQI$?;HpqB zhQ|P2h*^)=qCh=Nt4gUcGSmy0{Kh)m3s}^R+^&}t=$9Vf_WN--G|+DwAfIS!(9|y) z02X1JL!24Eg<>@`BW_Ej!ZNmzqCvZgO<(#Eg50Du8{4uU<K85K%r!JTzGyl+ zHa;4wI=yvy+ zs(z!)A`4nH@6(}}z!R#4hG$0tD5CeT8LicE2Nnz+Shj)+qtG<{NwjLLcy10_hZG8l z9=)Sju|Xx;6M!W^+XHH=P+!z4?>{Tsfq?p+@w3oIf)0&5wcT%#%SkXm>CCAsEa;FI zK4Q(%pPUE%Ow*bj5P`G@5&|$}I^dITX-=?)mK1b>4O*hPDMbS=wS1%~TbV*$+*VS3 zkzPkQP%Tj>hfA-10hgZgncpVa`7-uDhsEx4k)hV0EUuVfM={b04Lub zQtiaAl~I}P{h=D98muxdT1TH7gCEw36O$JBGOT~<DrZ`w7(OvI~0-AF6GpK1(lFkI5Y5_;$Y$`j37(Ug`Kb@41N z?M~6c*deykg?6EwKctnt0~y)Mo}#liffG4T@+-&A9eZW`-1uwl#MOPH@byBztmAqI z@7_AKZ3$xCJ6_sx-ZESIzNz9P&<85cRS5PR&Ym;Va(UyWjY4kgTrY2Ln}3G0cL?_G z>wBlVm&k6?SF68Pd%j>caprWyQ#Q4I3GaFK+_SHI@!S_@w(*|&sqG)aD0An*Zw8ZU@t&59dK`z8%@oHQWMM}$OfGqW@bkL z94gC~VL4#B`8RjOlPn~W9lt`-NJ&>cn{ke{5!2ecjkG07GkBo-mH%T~{-?_sjI^IE zEByD6!hcVi>08}7h`zPf*pq2`tKEzE+t!qx?8LWoIumj6PQJ0H+VoCA7RB3)J+-EH z))pZC!%QQE+4i0a%MVMe6tAT_lLa~;s^JTww_eiux60AQq*D_oAXKL;*BApt^JE$)b7K`1xBf}WDkA5 zbQI%%1sx?8qa;cA`{00tamqb5T#w$*kb6c?u0~H@1{f=3f8q~$(U;3+2A?pe#e)d= z@;4lX)V<%l!_(&tTEI#qm*@8_kxVo8{}MlvEPjS9#g_h`(?auUazBX@pFVXQOAj;R zJQDgdyzo;`FL{#I@B}dQ3|P0FJyZ_O2~MVz(hYR$F!EA{i+x#A7qKOt=LbDR8xHg9^8w-VY+h5%)U5@1x= zOE`N8Z!eqbzT3Mxl18FMDtJ3FUlSEnjbXoKwF$M3W zL!L7$R<+MbNM=YutMzENdMtdPdkVTM)=^ted2SE;ux_YjD=Uba19SSmC1Kv{DhED* z3-Xzy_cTnyKfubNa6zhMTl#*Qjz2)92Y~^pL?1LxJMC}KB`x+Ly=iY21!qFE;)*2P zq!63yY=+O$UAE7qX&x^@;FS0!Y=nj$Lv8+n=C?-PMqwBZ@db^dzs>JE$gk<`O0Io? zFYxoJgMw=i3`4s&b{@vgxFw72%g=u4*)PBFr5Da0p~)#&%Al;g1oMx!-?p7ExmbUp zo?Kl>e3fq57EwHsqViRe4eo%EHoV!4ud)^m1z)ARmC4&QfUnYJ!v*X!b~nO4Vk~E!^9Ed;|(?~EM=LGRQKxM=oTCfB#ua^R#N(Nl3q<|i%oI=BF(|a##p?-!L&)Oen<|@ZlFU9W_+tPj(HA9yuKfkDVDEKkH)?&=-VKRjdjOL>h*D&Eu6hPN7J(|#Qx*VX(-=*N|6oe^wkAhox+8=t8(j24f zdWs#V*cb&TD0m716jdg$$%d*Husf*w(8beZK{+=3To4r z@B_+AQcxlcAO=9_j}gQHg3vG}q2N;tuP||prwkvuJX1YO?rbtn?0&^{&J`^ziWE0< zg)Pydl1M4MLAN7vE#pc?q;mcB1ANhTvc6G%seCqX?ioI(F`Aoyx$RQhY~6e|>_jXT zmd*}DDm&nc{Ca>Z>$=g&7xqN^AN|qM_l`ya$A$heu7B)m`fLaeh)a3h9zIw z?BThbE5Z5GT=|CU!@Tbi7(JdjbE%JXviDxuJGc9KfG_Kg_U-&p`FrJ&Jx>UIhq%5& zS6k;&=5}#aRKqINaG|6J?viiHuw^f|W$)Fbn6ejqk3=4S@}bgHR9!oB z#i67p!`qEBb|eh zz5Drfk41)__*-B?_V}_uq#<~A=+s0E z)=4DE-@xzQEyN_O)2Ii&)V;4tOq3TVT?f(14pmLBCk(2Tp`=G`q(j37RB$`_nTo6G z?u8W*H9exM{7zj5Huu3cb>F1mntHoc`AzadsJDC7^pDOic2zrnvCK}w&kNPQkz=%L zp(BPWfoZ+QP-;+(OQ<+*FiB(GOd&;Mhn}DSN$e)}m(>CN1dvGxT7djmhswma0If)r zTMVMJ-d(%yT?cS;g>WyI1xuQ6fUx}_YEOh?oe=2M<3+)u8B&(3CCD)i86ET; zpFy7-8+2>*u$_8Z*K+Fu7DA$eo*_kRdjEgXFH>PIcu})8a(&dZTz}>=xJJIOpH_Z5 zdfpUFM$f0w=PusiPoxsWa)N1bZ^1T! z-ZIcB=T*;s4T>(;qa^_~!3w}?l8v0%1Kny4Ip(+d?Hxvp;B2LRXiXqnstH5NgK4{~ zF*3HudDOF#2Qx+U<#f}^t>1`V#VP-Cck4C(u#p(eDyO zcP9kWg86~;Rnw=(q%T1F^k6|CW7YH-G3g7DJ|kEd$k(PH>Q(y28^~u{ERVeE(})fF z0tKt4FVIO}6ewIZeW6bJ;($*{pNT%vtiKw^#Xk9~%I7+;Nn!B3C?>CFmDc1flJm%C zme(FAj>+p#S9nd{VmXg|W_ca3ZK1c)xb*0`7&(1%AFJnpC*TewYrX~1@6~US@AqoI zg612)mqee1KfLo)qUY*69+pPc#s+RQGO~ z?%i^I>J@{KyYK}o0u_4rN3b$bDSsUqLJlFlcmyhS`&rqxfeILlWmKtk(|p@1-4zaN zqulfH@JQ_gxxa$dpP}T`&s1{FXDB)CGnHJc!JsOYmegr5DD`eVzE8j0@9H_eX9D%X z2FaV<&x)82%U{HRyddAwlSG|&B@#~4dI8Ya;$IMq}dpcU6t_!QpeR-uYYG!9F zBy9G_;5UJ_fewF3y+Sr-5b#l{@6>&bEl^sA%F6v4T&G!i?18eFXX`c39D(wfXB#v- zQheD!!fs?1mreFV%K1Umzrn)$N{cDFhuTok(sdf&cB*i5aE$ArJKo-lo%gz0Za z2_{`QY|{JgE50!8ogZ!_Y=pyCYoJ8aE3sd6UECL4ue3fk-TJt68&*lTAugTTo50^= z`b-%~fzAG^dS@x{4mk!3Ue^9+BqDL<@)UuyMZ-}&FT6wA* z8j3V6gCb!)jw}t5zDP+|Y11Jt-BXom;-E=QN0ST#!JcCE+ za)>UEQ?Q3FPf&~vB1W+vr5dti#1@?*P59{W5!kI;^s0(PY($0S z;f4F`#$t*%+~jM<{8(`h!;X2E{4u@o1hv3HHew|HOlX{LKTWq?;fd3sBctN?4KF5! zLq|eKsj9~)IF4X3A$)St%;rdVF^N=JM;0CQ_ObAxqoL7Jbvi8Cr3p29^ys36rjr=K zWI`lvq%V3^u1IW4={`*+xux>8wx#Mlw3KmBn6 ze@DU3C`0PWiO{JrNB|uQj*h~1+o90#v+T=aC1ON-=x&WoFgDdehv&PkqJbli1U)TJ&ey3nxV0v%#}qi`f2pYDX`uSO`JY;^7JV-dqT7@E?UnF zgYAXxUraoCLXxnSvZlp74wnD}n*MCUB0bFIAJLs54)2>$npY zoE@CAV_{vNur9!@3-Hdpg7XOHJhCtn6h_W)BWHN$v#~Bm#T?&n5Z3MG*6rn;`=Dn6 zU!&G|U!%OO{GQ8_3yC62x_N3BtR1*>1y>#Cs^eV^Q$2Sa$x%=8wC$s0-)sh-TshUZ zlu|s~#-~(I?YL9lc7FT&X;s&|r*?jr2{q8PrS!a+JzqO8)xVVOn@#>E3?gKxu@9k$ z=Jx&A?E^>6?S19xb5Bnv-tnaT#F>W7WWZj?Y@RIMmym2zpN3+}S(r>7H{RXez% z5*=v@X=Srb@28bbcSk)bf~O4soTn`6%@@3-oVPSuQhsgomCZ}l4fBb@njUUV4`014 zx}#s%aTx#KX`TxU4Lw{#&qB?%$nkNw&n!>*B~1CJE+-k%%Rf#rWaYu0eOlJEjRkic zSwHFM;nTPMZ~600=MTfG{X$0lpXN{ZMAEk{W#!KFeA6-At3;x?m2=6^5s%b9!sT{O z@BF)iPw{Cd-aq(MWZz@w4_`cb;b6S_UoxaM=#gW%QyE$oPE zW6PcT*4NT!o}Du;WLIBriM@*EmcxB6{b!a&3*mjXayj2q1Qw{ngQw`@@dQH_ zrpm+59{82>T<}aM;e%uLEcqJe%igT~ZsqkZ-nUuE**xtLy(BaU#jRX%Yox9Bb|YWB zODG|ruX8H3Y~rJSOxC9^R1)xni_MAr4)?&Qn+g>rvnz|R*C@;L#N@(@XKtLIwz z+{TDCYblRJAM+qznEhc!mXJ}04=!YMMH;*CvVxM?E}^uIE2Zyoy_PHJiCA-&aw>>* zJ_HwQe9q$-UmJFwpNLepzn{^LTHu>MvICu5!{^n{=f0n}A+jfcvhQr)cQt8tg7>vu z&wbywC30{$V$IjR`S*@IX&`_t&X;fC3pR4jjWiN`(nyHZck|U-_~Nacb1NGQt2}`0 z>&9Kin7V<$+iiDIhE~RYt>G^>VQrf4xqRxC#^9iy6v%9&XmS{;?qB~wbQc$b1tq7E#=C# z+!$Uc+#Wf0BHFY4-J>^;-aa7o?B{y+qldJmF64DZo;dtaX-dkjwOwgL-#{3kq&w;> zzGl8+o_%5dH1FHEobD)h{}S3(9(Yzx?f6BWA-^^1%oUu4oU?G&H5cNYP4hb-ikfdn zH+*b0*s`VhQdr6+N>TZ>oGUref)b&ii7RM|7L|*>mW868>wQtUb-)Z~kaxKA$eyd|k&5=~MGHk6BKwA-MabW@oav-#uD=wW3y@jLbL5Z%d9z{_;I2kx8Qqak@oGk zJ0p+!BYPj?9)0Y+U0nNt$iY#reKd0HINvn3;C$*QnKd6L8EhFJ8;}*s*co}`kTII$ z6LK23oCf&Uo^Ooimk9Z-Tz;#N-+etNno%CjERJSY;QvnR=GOwV>*k(W$Zxzcc&Ba4 zYh|L zx%-6Ckg?tP$z8i4ecX8Wkpx5TBe>e0fVxU54c(ceg~qqCjiYwcTRB_GadF$!S%=em zjh<1L`Ogidi2r$M(P)bKFOqsKxL9x+M>EX}u6`>newJP~nq&Iex~^ng{2wj{g*izG zC(~k78VJh+q8p>PKtwMlt8oUAU&De!tW>_ng2Vnqh-xLGP#p1+zf9IW3(~4)X0PK4 zFtw^gjc_`lZnAC>+h?d^rnK3J6D(RxQC*`lL>p}keHAF?&k6f$LIz2fQpXxVa`9}( zd?lZ}UU00BnAT$+x;OvCvHp{A7P7sRLzZ7#P2WoGFwPs-n!cOVVSLjFGp(5`y0z|9 z2!@tpq+mb=6MOcr!E;>Ev$~qTfrQn1%tw;M^sV9!Rdi0(<(27RzlO$djNG=aoebd8 z;-M#NEx%bl$A5Q-vEJ9D+I=-TOb_gV47qOd`*nLjNu{gv{NNr?^V1lKJLEUZ=M_Cb zdMl}MnQ$pHEbrR`<~7WPIH>%fL6D^oJ?@-LHVhD>7lBMsX~XGa~L!VjTKnk zT=$a;lH0yJ^FgJ^DWRU3U?PsOWuQo`i#80{R4VuY(NE$I^h0UfEnLcFgW7&cq>947 zr3sW)yFzLGE%c&2VAqrF(bU|2zmcx-$zUI-UcDAE>1tO`huYMye7b@@YeG*ou1xo^ zwO0ERGB0M(jlV4R5JYC}lJ(vMa-0n}+JD2sgAzs5E>T2b*prs_pMx50ShSH7o$&FA zQ{fQNFkw)Pk+EJwT-@#xQDD&m+5(7gp%6KltIF1__J2ePNx3;xOaUW={fc7WpQHbN6`W$8>O;ZK48f7lIr4c&q2MUt93{M? z3`QKyhPsBS-GAdrolcB8+^=kUWz)G$ORXEO`+xAnpFVMWuh2TkwGO@-o@u_k>Cz@4 zx1P(b=X2LAWHv4wfM>N~;fZnXiE;h_ykb8SnRq60AT+(5;lt&evwZfs`4rySA~@G` z&h^*3g$@1OhW-znyO%8nZyInuTN*6rWE9>@wP)DxQtZ6(V~-&->*C0Tk)?v7*`jL= zR~q<&rlr!Vxgw!@16RF)FWpFcrp{|Suk3{8Ni;W4v?M3wHqM2jxz%C~zZ0Hsd~@@6 zH}ehMHwO6J?Z0qYT<&Er-mpyX`Z&=^IG{7he76CbU3U}E`FFkOs88+|;PEG6qMX7@ zbs12J-b`5ZDMs|>W@B%T>6W3(fQz>c#@>9>+s1Z^Z|t(;^sc4L+3PdEmzso&_j2IU z+W1~R3w?AabP=N{2HBvo_Z#@#Q~krK3a9&(C4PnQ1p#E5K5>UXL0&$Yom9OPil`j< zR6C3mfutiC(#MP%(NiK_#~rFA0cIfG7WWvJpR>v-1Lzu|U$Ca*57E4r32$&US%<6wJ<$1&_b^uVXa zX2nC+Nw?-J>j42?zo|oMkq_UV0gGB7_A`uemNeO_GR}sB@HqmOz-YtilhF8tMytq1 zlc5LWqMe!;89occMU3Y{E4RE2P*!mfAkYMWW-DzJMAilkQPRssb`3A47&(hh!k#?={0&H+DM=s$1A9dp5dc3xq*^n z9R>|Tmyy|LA*g|D1P4CKsF<_y8Et~M4OXI)b1vpz$d42@@yX3_q><^n`22+4;v=W`mA>EZ`^ru@=Sa^aOPS1jD!~oGTFF@}m&j5~CFi8& z-Py{nYB^`^d=jp(-L~0b%q#=Tdy>~YwH<4zGfi-mbB=P}Q3(ZGi(|=G@twp-?FPPb zBk$Y9jAn{ttEax*z~?ll zn7lc{#^*c?U}3^A*nxBW@tGA$?*Dz6*+;q6@>v%y5d4ih4?w!=vzc=7Zm}VU4e;dVA-1c*1x1^wTH9KFP($A*9B=r(seO z5CU99sON>@yXq9N4kv=XlVe9tP-4IfW`0F-%N?%oukvUXdm1_MY;^j*p+t<7(6=Z? ztFCD7?%(J#M8S4?`X*v4mVK4Iz35WhcZE-np9*ENrT?VlDg@l*VUT}L>8$?DO%)3^ z(PH0@chO=GS}v5$z>K`Q_d7k`enKdN%38WZ1b9OGs+cyfPjSi`O-)E>^(y29=OI%&K8nux#U_txqjLVX6BNw z@|x%0dw5^tbkBk_f5}Z$wtC*(DOfw-w|2tJSaxHqnK90kez(W~B@0kVlQzLTLb`d` zZb*ZnWl+7lj7<9)3K^t*-BU%{*FCw=zV0;P;+D2%}Ts-(;y0NFi^ur7@#S4rS7O}8|g=H+PppXgMEZUj00IZE3 zomjMw1}soGhW>;c z;O}|_KY7haRY+8Fy5iJ`^0Z!sxJ730S9yX>GC@~hJc40dKmX#YxtQ9dZqHzpj#Mz$YcwoF4M)dIMfPLZSS1gUNwprPm1KDwSWMM+($n zT*>1KmZOT1?tkWf5IJC%-^2*A*f?NjDEmSAy{H3(LB%6r3D_a32-^i6%AN~j>w6Mp zSIrus6qMB<=TpyeJL5+^!FjevOXw#-M`QnQ0lGsvrayq6mqB^Y^>%yjQJ=bC`jG8pXyVKm`JAl$LNOU~ zE3WkwlM6;VPe7lXrKXGE=(Ezr^HsI>Vc!5lGrAe^ZRf->A6^_fOC^e~E=Sm&adh~| zao;c#G{P&yTG!kAi5j6C*cTX@@w-=zKw6ziff4kFiDbWl=F%C%*OSoCz~pEyA2W(r z50l~S549r!Oxpb;PNAbHI1B^;#6D5n=;w$pw#c0!ZS%s1SifmZRM&49pMcC%cvJlb zGJE;l$s!}(38X;~EeUBZt>*7gXH_!zxyg~oTg2MlrB7a ziqce>#QiQKoSLZcMg&z^8r+K35HZp3QS2B6|AhjEJ$;#CG;x`0H4&})6T18d3N}!Q z3_0ti%NrC>)8g^6BD%*Au@vzIvvh;)%C8dM_IniAC>Ws;V z`)e_Y4X?!{abV>m^`cer@WwtQ>oyJ9R*Z_o+oA`4)6hJM44`|%rHC-pF2|sC121^0 zI8POCtro0nIP02)#!jJeH`llu@&{a_WNAuq{szKuN;;R;KHDLbv~ne_3ngp$w01tZ zeX0+SylLnAKJXNRwp!r3{M4nVV6ptw%|dE3m)gt-qw6^5y6S(~_GuUBv-#CRUK5ws^g&+Bxt-IGMV%S1^q=dW>G;4|b}td)2g^2t*9Y;m zl#HpqI~GW#WzUpzo}y_JVz0b#?uD77vxj+46|TKG7u^@!Ghg7nHPcDBnpw|zDa=HuCKdZ8_C`_-S?k7+0%)u z*=4eiSw6F@UR-?tW%eJI62y7%lU?bV!&G{vqw5E`h z-PpOYubdF9*&L)a5^?XY-w0P-cirgq&v!x`q=MjOT59SGmE7<;o!?^!Jr zPX;O3_+AOfy>z`owitD15Zajdy@kb1_i(&u!J})M< zN)ffnZ`V`Rm+=VstY>0cWsB=6wZ~%0ddyc%ejRL650cB!SFV+O1~iW8EyfJj!3E{~ z_uIjf=>@PdlN46w31DDEJyEbHx!PNqRjh<0`{wEAr{kp9YH6lI0jvM!FyGEQSRL7IQ1#Q%W;v9np@6b5D!VjUy6l62<5N2fPdzfYy2#63!B_Q&P2QURC#+~pUF6ESCNHrQobJ*IDg zcZncynj=g=;)-=;QP7^NPNikBGmV|IJuK^Cfhl8h2)*S_wHCo58$~uX2 z8)IvYRu#5ae*$=+j13OpX8a+K-x2J7RN5kqPqNqz6Q9_(?MQ&7Hlr?0lvY=E3?aXG z$fJBfS>xmy}MMVrcOE(Ufo@-vSXF^ zwsPyS7A74>-=8slJ+VK@N%rj$@3-FFPuv7+VS*mt3s%B0Z|MjoF?Y;ZF}J_WcB=Xo zy)Am;G)fr|w^2p|dUsbv%!+2!v*-`mpY??cCUC*!S%5(a@+5sJgT-8GYt7 zW-zR@JuBU)WHVZ(`&QNe{A=jgPwkx}(1h_#1jo<5o&dELXf&M;gS|Ea3yYv0508yY zySKkXTTZtjU`v8_dsI2}_(=W1nySi%$DggK+AQsn;_iytc(;*1^auF#p%es@xjwN@ z+9Kr!LF>%&G-%kc&bw$4&0mgGWx{}T_|Py_Sv0h{1#Qr5v5Kht|4jGTUWvgV25J~c z`h9xJHfUx_qC%ssbom?wLzJYCVr<6+kSlGc92z6%?4?Y^g#jlE?3zP2Gsl4G>%ca=ABNDMI`>p0dw_TQ z;aA)3nc61GuXvg4%uHA)Y~%_Xd2iEn(oY@U>DHyJ{F%|qCoY|sEBa=T&#EIWlNEP7 zDd#)5)Jp7>=C=RX(-O_dx;S=WY&P+0K}h06pbO*8oN2t^{HkTyL((fL@g6s_i)Jfk zvm@18Bbks?KfpPLBBr6`2DD|l+>l&GHiY4Qa0|ZhOI;g!>WsHG5kdRbW{|z>U?>>& z&+Clul%`SG4l=g+oem%3KLl~S#`MD+H^r-r6xL7($a#49$O)L)AFq?B+i)9o6mv3o zWHcNDISF06feR#? z`Q9pBnIi@&XEHd5HpL$rvwY?9Gd=Ga^4oPWCpD*6E<-&-Bva=u!<^K7I`!9w+Ik|b zDvj9h@H;zcXkZDCg7-fXo^+Hc3Cb8+Hfh;87KF_yAr(NoWkjs`97{lR+5FT<;-R^_FDa`25sMr{W!Tk=E zd5D6;2v))1WQY$2g~-oVze?yG)H6aD+!{m}2Iu~7jKS4$&bs+D5>9jF39cH>RU^38 zaIQ5_Y~)?tZ%+t4BV5mj&@;yMj76S$mhX9vb3F&4WV`*I#Y$+Nm7#elx@g|tJ3P_c zuG`OWo;?xk9w2=wX%}-Y`3ca zaRUfya>_5PhU5~#+sJtvA@s+2+vcC=y<4Ft?sET&+u%t%H+sJFE6498+wwucw`JO= z9dIP-$r0SOoV%8H*H0(@)Zw0P2JKz&7IEI9*%AnNPj~**<30DxQf19t=NsEz-!|X* zhkbwOIiI-Tg(fybI=^OFt{|kddIi$yo6VfH&82W(9*jKo%)%F6 z{1|S&6SgMYGZ>yX_9QGD49;x{xRF(iZ^8-e9yH3;h)=#eNCo6x0g~J84xy6IGGZoWrckf>|>kA z{K}#k23Ke$GvQ70L9`ZznL=Yxrs*gKW*Pkk_r*h{->5X5hjhLJh^x}aHRaG;X2)lB zyaVI73YBQTDX^-89!Oiz;v~L9F@guU6H8OJFp8A9&l0p^e{U@|eCGPtRH=^r$M#>? zpWruln1gmz!VuhPONWuE@Vx@)bQr%!@!O1_k3lMHe`pWZjSw*pRNxSz?}GK7?Q6+J z41{ZT(#bBay0vJUhD!T$q!z_{zf7^`5iF)0I((YUkBcncL&r}&Jsu(<4q1oeU3y`h z_ZiytFWP9-kfz3P=v$PAN(-H*B283?{tD@2BTjl*=r57aX2p_7WMNtoWxq6YiIJ9V zoO^r3x?RUFjrM6O1hw91c)j6z6Q8xY5L?^a#$LsjVM5@@K~8x88n?bBs)FS#o5~nm9*U#8kGNEqa%M0;9L} zC*C$lPPw)jKMC(cg#n+E|D*vRB7Nhx93wu8ncrNX!VJWQKdkF>StXAo_%Okas5d zPBG}J9)P$iqpC6@3DjRdt;XLHM0)hoXk1%Bm`6X2#&RwYO8qoIPUU(*j7L9>-S1G+ zIPcR&r`pC9x?QU3g=>|!DCjuEfAs62pyELf>}h)gcFJVFfx_PefWSnc0d=^`|kkIyK<+rw3Az zGlTU}vYwr8O}_v!$6}VOr>`lG5R{eKoWx4^?s`^4+0pW}Rr)7O)qq3K>_E??2hw#% zW48PTr6pLd{W*bj(K4bx*CUEs<-t01nm?blC*5B_@IcOqx)mb5kM8-4AYw&hKiyxv zB25NTmNbhb*|c4|l#K_?p}50es%-&Tp4D5c39>*7*`Y>{WUtaA zG39Ol8G0l$kfqlnuq@5WUZZR%gE_EAtyjl#xd-Cbr1Bl}I(+kCD_WfEcPOK@3Zpau;&3L|dCvA%vwqL?*TnUEZ6FW*UKbF1-@?XF zb|6Q$MfKWVXX7S6kguDvVU@P$`y1lgUVyr-VRg%2F>V?I`ElbWf5o_I3>0X_O}0F4 zc73GGg2Gk4c~eYlUcxu`1$?@#Q7om#e978?&)-a~!TVd96REd@MXQvc*gREA06N23 z?en*4zX(cc3l>XH)@q*wOEgxYNo=vu-ySH&yE_6!2-gKl5Uy_)zhL36r&x*&bXQ}E z+Kf54k)_`x));v{Im)a=nSHFxFQ63t z)qjWfyY8)0gaq>Ng{xp`RIlbc<(m6nK5gugmCqODoja&3_*(%*a zx%F-)nB z1zfm``id*4|9HTQ>#;y3!lwdhm{Hk?od}d89M{;Cr?)>HC?p=DKS-gJf80Ouunwm6 zL~8seV|x*AeWp22aq|SbosvjM6&yZ=CTq4qypk--5%(~7s&3-ssWFhuKqT5exNB#u zwN6onXJ%OzSYVfho|$`%2f%j`@>vast%_rW!>fKA}4jZS&z@G z*0F_Cm0bSFA0rFr>Z($e{9ey2EFZS-^07$oz3J(B-Tk`zb3rp0CZZ@6c!w({6RyxYow)hUn_{_14H{koYA}#wtJ?$o+)?TFt&kCbW9Jq zT=*o_-aLK7eqj1W$0S@-dZy^IW7sFU++v+Law z_9g`TeO`~tUha0gJIXrQAy~nu9Ss53{}E3*Ai8eI>lvGBhkJqX03|X`welLu!n|e% zvo|M)E3s4cLn>0lA5%m>QH3Zvv*e$-iA$6_UQGH_rU}1%w2}Uh%>P~{E|*KKz3Xnu zdV5|nG{8{zp>{Ctrk7|HY+d*3YpWIo~+*CN#T_pLSoq6$VWIQyA zzu!Q{`?PjvC!*zR8=zKiYR@TeYilD>|K1PmLGN!Qfp|*szPY`HYYA)<@un2Tzo*vr z9Eu*)RBceFB}4k5s?*^Qh`y<@&R{BftwxqOhQ$Wl z+346+T7s>ut#~GwuJv$7gV)m;Ecu2ckSm9KvNNc6G-wD)yiRJ~MuSu9{lek$T#NT0tvCVSbbB*ZQ(SRu)ot18|u-$e3SscDN zS!+Lv(QxAQ(8vXLeH<}s?Nt}tV?MXNzo)9!?jM@+*I^r{XS4(BR&kLXU?M{R8!j8^ zTx?$l3{bVvl;mk2BHIA_t@R2$JPD(fA$2xje|9~XT7{O+`16oNZ*%V@?UWcfFf{CSBbM3(FmN_jbRZvW%ts}9;Y`Qfjs~hy zRCZQWHo|VsUtk<;5;Tt8>mChk2!rMo>bM+dujvyzO**uZd1Pb+sc(#M_VUqI`itk7 z`WCBMN5eR49u5+jTG5X2rq19k+-TCS5m5F+)EFsNucO(6+tA+TSXtNq1 zF(x$S7F=QqW7ZVhdaa6Q(D%hZMhgfU20U&r%wU3sqqOb`BGn#uiyX{U+bwzsBZ7Jr z=^W@XrRu$H8mv9uBRhjeRZFN3{d=1Fz<6_K&$@|}AA)Jt1jFiFrKwRluri6Rw!_?N zJb?i^*!adX+H-Lwl2GmOna{bIAsi4NnEh>^BPG818C;2-6tu1o)YMtk2W_~M&e8FFB?|~b*^izctbgO@L*!y(v&x0%wR)%S?3ef4QZ)2 zrNY%vG1*Wt5HzPb8q};H5z=MV88IUfPHbEn!Wi{SY7A+l!2f{29}>7tV4eW;7e#yS zVtRPzhjS`=H5OIJ9vCi0@!sIBZV-QlAw^v|phW|btuw5zW5jewN_~x>H;9;inA+M_ zclPwY5KEng4tEk$@+jh)(8y8m(3rZKaffAvb)J}Uc*r&8cCy}wt%Gn-F8Z$f#a~bq z^_W=XkogLanVEZ%4gceQ9Lx!);}kz;Q16LYLl>Q7bO#sdD&h1F4ZFSUs8+=CFux?R zH0b7OznYu>qNH@L8IjFWVtTyZAjQ{7L?J3px|ZxDs%jQKve1lJ3MykbRiDHLNR8LO z5wA>b_;jjwVg`(k&Kc1gOC_0LB^vhCdf3tj{IM);@O4s;chdWfzX54dGyE-n%#2I? zfU1uG4oBzw-S}VtcPnYuxvu+IKYjM)=WjnBx)v5?(_YzFGj}X%DSo}-P6LjV&6O<& z=6a*)c`u*6eO7|!T^Lj~!pBN}`CFzpOzMHO`6s0$Yc#9q^^!X!IFoip&T7Sq@;ZwG z+0rrBd*5PRn0VcH$44G)<=g`+xt(w$Ya6U!o=-9? z^L*5p{&L659k)B8rDZFnEfLsH1?AEscY5y5&UZ(Pu1XWrSgOUEtjM3CrCHlqVZJwN zGOw5_@ehopl7kWR!3AS9-5NTyXj{x!I{%(-`N;dd@Aa-YF5YYUdB+DG@_{F%{gKOC;9s)d+&8gPr%*ouzYevI^jaFn;+n#17zUTd`C|u-t~Tf$4bP&eSlZPPu{ADbNrf#-O-oNM_1rx#og9dC z!!*w+cRMB5gyg#+yKcNUg35ZHKSFiZd6Zy5mjIvYs_X{&`-4vDnPF+uM%vZ1)cmof4Gxss z4*YQR`=iTOR@!ACFv53QW3NvXsp4Rj>6KZFg7mXFP~usUpO|Ka%e$EC;4 zt+btww4GlN7WPT`dn4I3lBIUBJCfNfU3v;FvvTiWj(>N2@%jp!LRap;qgyx@%DZ#= zBXiku-@N|5IUCo-k(?^oyayJGMl;MHrJ}C8-FNrjtCsqnlrEqPT$gU#lCGnqs2mb8 zXFEk@$~Yyw^!O^p?^y0$-v9By;NAXvE%z$!%|yETrD2zJ)GfKMN`(`V^of@q$M)W= zI_%oWFWv0FX^VTOZ0p>qs3Bt^S2pB*TvQv~fAp?L-hVdK6P^fPi4?X-{bTbcMG%Ut ztJd98PDS{N)Y5ggOKLs|8TRnC(b~2pzf{wKD~AakB;PWh4*wcp+e>Tdlk8IX_|LBhd%~BOQkJuq``#;9HbQu zF{z;Gqx9yl3XkU2ExO;m`p(s5t6cw>RCoApl9baupSoJtvNS8#9ggHz-yMclR+5s; z=}0XjYrYL##DX0wN!&?cfGYEZ@Dz~oDIpb}o6|DqVT~l12Km*&v6aEe$l#gpLWPsR;#C1uY5z3QWBQC|ncttNP5jUZiN&e`c?l^Urt8*DVAh`l2O^ zl(Y}wt)KZLmsKzy4DAUO!u$1ZJ%)^A%iI~T%+~*F>tm9> zF!WSJU$t2Ep}q+kCK@103zB|P zN}57MAD^OJ;A_&6XpP?Z>o1PLhyK&>T|LNig;TsA#wXlQ8_j)tlm4-@psyk6=eA@( zk=72zaEs#(zkFDH6$3xEeX#Fwud8$jNPL56o_$Qk2EQaS;)fO8dNgr8lKQ>%A(+PW z*M__^m^+P%zDad1V+i;dvB{{FmKA6s_vo(yUga)X*q3oI=%*8^5vKljS_gs8YUGQ4 zQ@U@6xv@@if-5T4&v=_5|$etdXc0#oxdKg5&|p6X_|FTnfW7s#9VhyZ;W)?`0C z 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%0DV=gxFbVj8)nw|Wyw{@NEZhGyrKZI`*dv8$GPW4B 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 NNjy~IsCcFJe*lsR30MFC diff --git a/backend/api/__pycache__/resos.cpython-313.pyc b/backend/api/__pycache__/resos.cpython-313.pyc deleted file mode 100644 index a41ce39662320a97266d430bb4cf040c837914d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38301 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 diff --git a/backend/api/__pycache__/sambapos.cpython-313.pyc b/backend/api/__pycache__/sambapos.cpython-313.pyc deleted file mode 100644 index efe9689a23539fbc978664294bee1dd4889b4d3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29982 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 diff --git a/backend/api/__pycache__/search.cpython-313.pyc b/backend/api/__pycache__/search.cpython-313.pyc deleted file mode 100644 index 8c512c178a3461c86d53f76d1a830e4078f5c118..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33011 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 z 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 QnWV#}rvP6fm}vF?H`-hHi~s-t diff --git a/backend/api/__pycache__/settings.cpython-313.pyc b/backend/api/__pycache__/settings.cpython-313.pyc deleted file mode 100644 index d06e1dc990ace2c1e24f933e23a7a9ba5c3073ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34743 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 W?`qVh#gn~{C%xWu&jA-(a{eC-(gmmh diff --git a/backend/api/__pycache__/suppliers.cpython-313.pyc b/backend/api/__pycache__/suppliers.cpython-313.pyc deleted file mode 100644 index 6252f3a1ca9ee52af13c1ca19bc1c765c11f5d90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15099 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 float: - return float(v) - - -class BudgetPOItem(BaseModel): - """Individual PO in budget table""" - id: int - order_type: str - status: str - total_amount: Optional[Decimal] - order_reference: Optional[str] - - @field_serializer('total_amount') - def serialize_total(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - -class SupplierBudgetRow(BaseModel): - """Supplier row in weekly budget table""" - supplier_id: Optional[int] - supplier_name: str - historical_pct: Decimal # 4-week average percentage - allocated_budget: Decimal # total_budget * historical_pct - invoices_by_date: dict[str, list[BudgetInvoice]] # date -> invoices - purchase_orders_by_date: dict[str, list[BudgetPOItem]] # date -> POs - actual_spent: Decimal # Sum of invoices this week (excluding CD) - cd_adjustments_by_date: dict[str, float] = {} # date -> CD +/- for this supplier - cd_total: float = 0 # Total CD adjustment for this supplier - po_ordered: Decimal # Sum of pending PO totals this week - remaining: Decimal # allocated - (spent + cd) - po_ordered - status: str # "under", "on_track", "over" - - @field_serializer('historical_pct', 'allocated_budget', 'actual_spent', 'po_ordered', 'remaining') - def serialize_decimals(self, v: Decimal) -> float: - return float(v) - - -class DailyBudgetData(BaseModel): - """Daily budget tracking data""" - date: date - day_name: str - forecast_revenue: Decimal - budget_split_pct: Decimal # % of weekly budget this day gets (from historical spend) - historical_budget: Decimal # Budget allocated based on historical spend patterns - revenue_budget: Decimal # Budget allocated based on forecast revenue proportion - actual_spent: Optional[Decimal] # Only for past/today - cumulative_budget: Decimal - cumulative_spent: Optional[Decimal] - - @field_serializer('forecast_revenue', 'budget_split_pct', 'historical_budget', 'revenue_budget', 'cumulative_budget') - def serialize_decimals(self, v: Decimal) -> float: - return float(v) - - @field_serializer('actual_spent', 'cumulative_spent') - def serialize_optional_decimals(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - -class CoversSummary(BaseModel): - """Covers summary for a meal period""" - otb: int - pickup: int - forecast: int - - -class DailyCoverData(BaseModel): - """Daily covers breakdown for a single day""" - date: date - day_name: str - otb_rooms: int = 0 - pickup_rooms: int = 0 - otb_guests: int = 0 - pickup_guests: int = 0 - breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - - -class ForecastSummary(BaseModel): - """Weekly forecast summary for rooms and covers""" - otb_rooms: int = 0 - pickup_rooms: int = 0 - forecast_rooms: int = 0 - otb_guests: int = 0 - pickup_guests: int = 0 - forecast_guests: int = 0 - breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0) - daily_covers: list[DailyCoverData] = [] - - -class WeeklyBudgetResponse(BaseModel): - """Response for weekly budget endpoint""" - week_start: date - week_end: date - dates: list[date] # All 7 days of the week - - # Forecast data - otb_revenue: Decimal # On The Books revenue (current bookings only) - forecast_revenue: Decimal # Full forecast revenue (OTB + expected pickup) - forecast_source: str # "forecast_api" or "fallback" - - # Forecast summary (rooms + covers) - forecast_summary: Optional[ForecastSummary] = None - - # Override info - has_overrides: bool = False - snapshot_revenue: Optional[Decimal] = None # Original snapshotted revenue - adjusted_revenue: Optional[Decimal] = None # Revenue after applying overrides - - # Budget calculation - gp_target_pct: Decimal # e.g., 65.00 - min_budget: Decimal # Minimum budget based on OTB only - total_budget: Decimal # Full budget based on forecast - - # Actuals - total_spent: Decimal # Actual spend from confirmed invoices - total_po_ordered: Decimal # Sum of pending PO totals - total_remaining: Decimal # budget - spent - po_ordered (negative = overspend) - cd_budget_reservation: Decimal = Decimal("0") # Budget reserved for cost distributions - - # Supplier breakdown - suppliers: list[SupplierBudgetRow] - all_supplier_names: list[str] - - # Daily breakdown - daily_data: list[DailyBudgetData] - daily_totals: dict[str, Decimal] # date -> actual spend - - @field_serializer('otb_revenue', 'forecast_revenue', 'gp_target_pct', 'min_budget', 'total_budget', 'total_spent', 'total_po_ordered', 'total_remaining', 'cd_budget_reservation') - def serialize_decimals(self, v: Decimal) -> float: - return float(v) - - @field_serializer('snapshot_revenue', 'adjusted_revenue') - def serialize_optional_revenue(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - @field_serializer('daily_totals') - def serialize_daily_totals(self, v: dict[str, Decimal]) -> dict[str, float]: - return {k: float(val) for k, val in v.items()} - - -class BudgetSettingsResponse(BaseModel): - """Budget settings response""" - forecast_api_url: Optional[str] - forecast_api_configured: bool - budget_gp_target: Decimal - budget_lookback_weeks: int - - @field_serializer('budget_gp_target') - def serialize_gp_target(self, v: Decimal) -> float: - return float(v) - - -class BudgetSettingsUpdate(BaseModel): - """Budget settings update request""" - forecast_api_url: Optional[str] = None - forecast_api_key: Optional[str] = None - budget_gp_target: Optional[Decimal] = None - budget_lookback_weeks: Optional[int] = None - - -class TestConnectionResponse(BaseModel): - """Response for forecast API connection test""" - success: bool - message: str - - -# Helper Functions - -async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings: - """Get kitchen settings, creating if not exists""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=kitchen_id) - db.add(settings) - await db.commit() - await db.refresh(settings) - - return settings - - -async def get_historical_supplier_percentages( - db: AsyncSession, - kitchen_id: int, - lookback_start: date, - lookback_end: date -) -> list[tuple[int | None, str, Decimal]]: - """ - Calculate supplier spend percentages over the lookback period. - - Returns list of (supplier_id, supplier_name, percentage) sorted by percentage desc. - """ - # Build subquery to sum per invoice first (for credit note handling) - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.supplier_id.label('supplier_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.invoice_date >= lookback_start, - Invoice.invoice_date <= lookback_end, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type) - .subquery() - ) - - # Get total for period - total_result = await db.execute( - select(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - total_spend = total_result.scalar() or Decimal("0") - - if total_spend <= 0: - return [] - - # Get supplier breakdown - supplier_result = await db.execute( - select( - invoice_stock_subq.c.supplier_id, - Supplier.name, - func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - ).label('net_total') - ) - .select_from(invoice_stock_subq) - .outerjoin(Supplier, invoice_stock_subq.c.supplier_id == Supplier.id) - .group_by(invoice_stock_subq.c.supplier_id, Supplier.name) - .order_by(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - ).desc()) - ) - - result = [] - for supplier_id, supplier_name, net_total in supplier_result.all(): - if net_total and net_total != 0: - pct = (net_total / total_spend * 100) - result.append(( - supplier_id, - supplier_name or "Unmatched", - round(pct, 2) - )) - - return result - - -async def get_historical_daily_distribution( - db: AsyncSession, - kitchen_id: int, - lookback_start: date, - lookback_end: date -) -> dict[int, Decimal]: - """ - Calculate historical spend distribution by day of week. - - Returns dict of weekday (0=Mon, 6=Sun) -> percentage of weekly spend - """ - # Get all confirmed invoices in the lookback period with their totals - invoice_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.invoice_date.label('inv_date'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.invoice_date >= lookback_start, - Invoice.invoice_date <= lookback_end, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.invoice_date, Invoice.document_type) - .subquery() - ) - - # Get spend by day - result = await db.execute( - select( - invoice_subq.c.inv_date, - func.sum( - case( - (and_(invoice_subq.c.doc_type == 'credit_note', - invoice_subq.c.stock_total > 0), - -invoice_subq.c.stock_total), - else_=invoice_subq.c.stock_total - ) - ).label('net_total') - ) - .select_from(invoice_subq) - .group_by(invoice_subq.c.inv_date) - ) - - # Aggregate by day of week - weekday_totals: dict[int, Decimal] = {i: Decimal("0") for i in range(7)} - total_spend = Decimal("0") - - for inv_date, net_total in result.all(): - if inv_date and net_total: - weekday = inv_date.weekday() # 0=Monday, 6=Sunday - weekday_totals[weekday] += Decimal(str(net_total)) - total_spend += Decimal(str(net_total)) - - # Convert to percentages - if total_spend > 0: - return {day: (amount / total_spend * 100).quantize(Decimal("0.01")) - for day, amount in weekday_totals.items()} - else: - # Default to even distribution if no historical data - return {i: Decimal("14.29") for i in range(7)} - - -async def get_weekly_invoices_by_supplier( - db: AsyncSession, - kitchen_id: int, - week_start: date, - week_end: date -) -> dict[tuple[int | None, str], list[dict]]: - """ - Get all confirmed invoices for the week grouped by supplier. - - Returns dict of (supplier_id, supplier_name) -> list of invoice data - """ - # Get all confirmed invoices for the week with line items - result = await db.execute( - select(Invoice) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.status == InvoiceStatus.CONFIRMED, - Invoice.invoice_date >= week_start, - Invoice.invoice_date <= week_end, - ) - .options(selectinload(Invoice.line_items)) - .order_by(Invoice.invoice_date) - ) - invoices = result.scalars().all() - - # Get supplier names - supplier_result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == kitchen_id) - ) - suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} - - # Group invoices by supplier - supplier_invoices: dict[tuple[int | None, str], list[dict]] = defaultdict(list) - - for inv in invoices: - # Calculate net stock for this invoice - net_stock = Decimal("0") - if inv.line_items: - for item in inv.line_items: - if not (item.is_non_stock or False): - net_stock += item.amount or Decimal("0") - - # Handle credit notes - if inv.document_type == 'credit_note' and net_stock > 0: - net_stock = -net_stock - - # Skip invoices with no stock value (non-stock only invoices) - if net_stock == 0: - continue - - # Get supplier key - if inv.supplier_id: - supplier_name = suppliers_map.get(inv.supplier_id, "Unknown") - key = (inv.supplier_id, supplier_name) - else: - vendor = inv.vendor_name or "Unknown Supplier" - key = (None, vendor) - - supplier_invoices[key].append({ - "id": inv.id, - "invoice_number": inv.invoice_number, - "invoice_date": inv.invoice_date, - "net_stock": net_stock, - "document_type": inv.document_type, - }) - - return supplier_invoices - - -# API Endpoints - -@router.get("/weekly", response_model=WeeklyBudgetResponse) -async def get_weekly_budget( - week_offset: int = 0, # 0 = current week, -1 = last week, etc. - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get weekly budget breakdown with supplier allocations. - - week_offset: 0 = current week (Mon-Sun), -1 = previous week, etc. - """ - # Calculate week dates (Mon-Sun) - today = date.today() - current_monday = today - timedelta(days=today.weekday()) - week_start = current_monday + timedelta(weeks=week_offset) - week_end = week_start + timedelta(days=6) - week_dates = [week_start + timedelta(days=i) for i in range(7)] - - # Get settings - settings = await get_settings(db, current_user.kitchen_id) - - # Get forecast revenue + rooms + covers - otb_revenue = Decimal("0") - forecast_revenue = Decimal("0") - forecast_source = "fallback" - forecast_data = None # Keep the daily forecast data - forecast_summary = None - - api_spend_rates = {} # spend rates from API for override recalculation - covers_data_raw = [] # raw covers data from API for override recalculation - - if settings.forecast_api_url and settings.forecast_api_key: - try: - async with ForecastAPIClient( - settings.forecast_api_url, - settings.forecast_api_key - ) as client: - forecast_data = await client.get_revenue_forecast(week_start, days=7) - otb_revenue, forecast_revenue = client.calculate_food_revenue(forecast_data) - forecast_source = "forecast_api" - logger.info(f"Fetched revenue - OTB: {otb_revenue}, Forecast: {forecast_revenue}") - - # Fetch rooms and covers for summary banner - try: - rooms_data = await client.get_rooms_forecast(week_start, days=7) - covers_data = await client.get_covers_forecast(week_start, days=7) - covers_data_raw = covers_data # Save for override recalculation - rooms_agg = client.aggregate_rooms(rooms_data) - covers_agg = client.aggregate_covers(covers_data) - - # Index rooms data by date for matching - rooms_by_date = {d.get("date", ""): d for d in rooms_data} - - # Build daily covers breakdown - daily_covers_list = [] - for day in covers_data: - day_date_str = day.get("date", "") - day_name = day.get("day", "") - room_day = rooms_by_date.get(day_date_str, {}) - daily_covers_list.append(DailyCoverData( - date=day_date_str, - day_name=day_name, - otb_rooms=room_day.get("otb_rooms", 0) or 0, - pickup_rooms=(room_day.get("forecast_rooms", 0) or 0) - (room_day.get("otb_rooms", 0) or 0), - otb_guests=room_day.get("otb_guests", 0) or 0, - pickup_guests=(room_day.get("forecast_guests", 0) or 0) - (room_day.get("otb_guests", 0) or 0), - breakfast=CoversSummary( - otb=day.get("breakfast", {}).get("otb", 0) or 0, - pickup=(day.get("breakfast", {}).get("forecast", 0) or 0) - (day.get("breakfast", {}).get("otb", 0) or 0), - forecast=day.get("breakfast", {}).get("forecast", 0) or 0, - ), - lunch=CoversSummary( - otb=day.get("lunch", {}).get("otb", 0) or 0, - pickup=(day.get("lunch", {}).get("forecast", 0) or 0) - (day.get("lunch", {}).get("otb", 0) or 0), - forecast=day.get("lunch", {}).get("forecast", 0) or 0, - ), - dinner=CoversSummary( - otb=day.get("dinner", {}).get("otb", 0) or 0, - pickup=(day.get("dinner", {}).get("forecast", 0) or 0) - (day.get("dinner", {}).get("otb", 0) or 0), - forecast=day.get("dinner", {}).get("forecast", 0) or 0, - ), - )) - - forecast_summary = ForecastSummary( - otb_rooms=rooms_agg["otb_rooms"], - pickup_rooms=rooms_agg["pickup_rooms"], - forecast_rooms=rooms_agg["forecast_rooms"], - otb_guests=rooms_agg["otb_guests"], - pickup_guests=rooms_agg["pickup_guests"], - forecast_guests=rooms_agg["forecast_guests"], - breakfast=CoversSummary(**covers_agg["breakfast"]), - lunch=CoversSummary(**covers_agg["lunch"]), - dinner=CoversSummary(**covers_agg["dinner"]), - daily_covers=daily_covers_list, - ) - except Exception as e: - logger.warning(f"Failed to fetch rooms/covers forecast: {e}") - - # Fetch spend rates for override recalculation - try: - sr_response = await client.get_spend_rates() - api_spend_rates = sr_response.get("periods", {}) - except Exception as e: - logger.warning(f"Failed to fetch spend rates: {e}") - except ForecastAPIError as e: - logger.warning(f"Failed to fetch forecast: {e.message}") - forecast_data = None - - # Calculate budgets - gp_target = settings.budget_gp_target or Decimal("65.00") - cost_target_pct = (100 - gp_target) / 100 - min_budget = (otb_revenue * cost_target_pct).quantize(Decimal("0.01")) - total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01")) - - # --- Apply cover/spend rate overrides to adjust forecast revenue --- - has_overrides_flag = False - snapshot_revenue_val = None - adjusted_revenue_val = None - adjusted_daily = {} # date_str -> adjusted revenue for daily breakdown - - if forecast_source == "forecast_api": - # Check if a forecast snapshot exists for this week - week_snap_result = await db.execute(text(""" - SELECT total_forecast_revenue FROM forecast_week_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": current_user.kitchen_id, "ws": week_start}) - week_snap = week_snap_result.fetchone() - - if week_snap: - snapshot_revenue_val = Decimal(str(week_snap.total_forecast_revenue)) if week_snap.total_forecast_revenue else None - has_overrides_flag = True - - # Only recalculate if we have live covers data - if covers_data_raw: - # Load cover overrides for this week - ovr_result = await db.execute(text(""" - SELECT override_date, period, override_covers - FROM cover_overrides - WHERE kitchen_id = :kid AND override_date >= :ws AND override_date <= :we - """), {"kid": current_user.kitchen_id, "ws": week_start, "we": week_end}) - override_lookup = {} - for row in ovr_result.fetchall(): - override_lookup[(row.override_date.isoformat(), row.period)] = row.override_covers - - # Load snapshot spend rates - snap_spend_result = await db.execute(text(""" - SELECT snapshot_date, period, food_spend, drinks_spend - FROM forecast_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": current_user.kitchen_id, "ws": week_start}) - snap_spend_lookup = {} - for row in snap_spend_result.fetchall(): - snap_spend_lookup[(row.snapshot_date.isoformat(), row.period)] = row - - # Load spend rate overrides - spend_ovr_result = await db.execute(text(""" - SELECT period, food_spend, drinks_spend - FROM spend_rate_overrides - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": current_user.kitchen_id, "ws": week_start}) - spend_ovr_lookup = {row.period: row for row in spend_ovr_result.fetchall()} - - # Resolve effective spend rates per period (override > snapshot > API) - spend_effective = {} - for period in ("breakfast", "lunch", "dinner"): - api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0) - api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0) - - snap_food = snap_drinks = None - for d_check in week_dates: - key = (d_check.isoformat(), period) - if key in snap_spend_lookup: - snap_food = float(snap_spend_lookup[key].food_spend) if snap_spend_lookup[key].food_spend else None - snap_drinks = float(snap_spend_lookup[key].drinks_spend) if snap_spend_lookup[key].drinks_spend else None - break - - ovr_sp = spend_ovr_lookup.get(period) - ovr_food = float(ovr_sp.food_spend) if ovr_sp and ovr_sp.food_spend else None - ovr_drinks = float(ovr_sp.drinks_spend) if ovr_sp and ovr_sp.drinks_spend else None - - eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food) - eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks) - spend_effective[period] = {"food": eff_food, "drinks": eff_drinks} - - # Recalculate revenue per day - covers_by_date_ovr = {d.get("date", ""): d for d in covers_data_raw} - adjusted_total = Decimal("0") - - for d in week_dates: - date_str = d.isoformat() - is_past = d < today - - if is_past and forecast_data: - # Past: use actual dry revenue from forecast API - day_rev = Decimal("0") - for fd in forecast_data: - if fd.get("date") == date_str: - dry = fd.get("dry", {}) - day_rev = Decimal(str(dry.get("forecast", 0) or 0)) - break - else: - # Today/future: recalculate from effective covers × effective spend - day_covers = covers_by_date_ovr.get(date_str, {}) - day_rev = Decimal("0") - - for period in ("breakfast", "lunch", "dinner"): - p_covers = day_covers.get(period, {}) - otb_cvr = p_covers.get("otb", 0) or 0 - forecast_cvr = p_covers.get("forecast", 0) or 0 - - ovr_val = override_lookup.get((date_str, period)) - if ovr_val is not None: - effective = max(otb_cvr, ovr_val) # OTB always supersedes upward - else: - effective = forecast_cvr - - eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0))) - day_rev += Decimal(str(effective)) * eff_food - - adjusted_daily[date_str] = day_rev.quantize(Decimal("0.01")) - adjusted_total += day_rev - - adjusted_revenue_val = adjusted_total.quantize(Decimal("0.01")) - - # Replace forecast_revenue and recalculate budget with adjusted values - forecast_revenue = adjusted_revenue_val - total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01")) - - # Get historical supplier percentages - lookback_weeks = settings.budget_lookback_weeks or 4 - lookback_start = week_start - timedelta(weeks=lookback_weeks) - lookback_end = week_start - timedelta(days=1) - - supplier_pcts = await get_historical_supplier_percentages( - db, current_user.kitchen_id, lookback_start, lookback_end - ) - - # Get historical daily distribution (for budget allocation by day of week) - daily_distribution = await get_historical_daily_distribution( - db, current_user.kitchen_id, lookback_start, lookback_end - ) - - # Get this week's invoices by supplier - weekly_invoices = await get_weekly_invoices_by_supplier( - db, current_user.kitchen_id, week_start, week_end - ) - - # Get this week's purchase orders (DRAFT + PENDING) grouped by supplier - po_result = await db.execute( - select(PurchaseOrder) - .where( - PurchaseOrder.kitchen_id == current_user.kitchen_id, - PurchaseOrder.status.in_(["DRAFT", "PENDING"]), - PurchaseOrder.order_date >= week_start, - PurchaseOrder.order_date <= week_end, - ) - ) - weekly_pos = po_result.scalars().all() - - # Group POs by supplier_id -> {date_str -> [BudgetPOItem]} - po_by_supplier: dict[int, dict[str, list[BudgetPOItem]]] = defaultdict(lambda: defaultdict(list)) - po_totals_by_supplier: dict[int, Decimal] = defaultdict(lambda: Decimal("0")) - po_supplier_names: dict[int, str] = {} - - for po in weekly_pos: - sid = po.supplier_id - ds = po.order_date.isoformat() - amt = po.total_amount or Decimal("0") - po_by_supplier[sid][ds].append(BudgetPOItem( - id=po.id, - order_type=po.order_type, - status=po.status, - total_amount=amt, - order_reference=po.order_reference, - )) - po_totals_by_supplier[sid] += amt - - # Get supplier names for PO-only suppliers - if po_by_supplier: - sup_ids = list(po_by_supplier.keys()) - sup_result = await db.execute( - select(Supplier.id, Supplier.name).where(Supplier.id.in_(sup_ids)) - ) - for sid, sname in sup_result.all(): - po_supplier_names[sid] = sname - - # Compute cost distribution adjustments per supplier per date. - # CD entries are attributed back to the supplier whose invoice was distributed. - cd_supplier_result = await db.execute( - select( - Invoice.supplier_id, - CostDistributionEntry.entry_date, - func.sum(CostDistributionEntry.amount) - ) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .join(Invoice, CostDistribution.invoice_id == Invoice.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= week_start, - CostDistributionEntry.entry_date <= week_end, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - .group_by(Invoice.supplier_id, CostDistributionEntry.entry_date) - ) - # Per-supplier per-date amounts, plus aggregated totals - cd_by_supplier_date: dict[Optional[int], dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0"))) - cd_total_by_supplier: dict[Optional[int], Decimal] = defaultdict(lambda: Decimal("0")) - cd_daily_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) - cd_total_adjustment = Decimal("0") - for row in cd_supplier_result.all(): - sup_id, cd_date, cd_amount = row[0], row[1], row[2] or Decimal("0") - date_str = cd_date.isoformat() - cd_by_supplier_date[sup_id][date_str] += cd_amount - cd_total_by_supplier[sup_id] += cd_amount - cd_daily_amounts[date_str] += cd_amount - cd_total_adjustment += cd_amount - - # Build supplier rows - supplier_rows = [] - all_supplier_names = [] - total_spent = Decimal("0") - total_po_ordered = Decimal("0") - - # Process historical suppliers first - processed_suppliers = set() - processed_po_suppliers = set() - for supplier_id, supplier_name, hist_pct in supplier_pcts: - key = (supplier_id, supplier_name) - processed_suppliers.add(key) - if supplier_id: - processed_po_suppliers.add(supplier_id) - all_supplier_names.append(supplier_name) - - # Calculate allocated budget from full total_budget - allocated = (total_budget * Decimal(str(hist_pct)) / 100).quantize(Decimal("0.01")) - - # Get invoices for this supplier this week - invoices = weekly_invoices.get(key, []) - - # Organize invoices by date - invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list) - actual_spent = Decimal("0") - - for inv in invoices: - date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else "" - if date_str: - invoices_by_date[date_str].append(BudgetInvoice( - id=inv["id"], - invoice_number=inv["invoice_number"], - invoice_date=inv["invoice_date"], - net_stock=inv["net_stock"], - document_type=inv["document_type"], - )) - actual_spent += inv["net_stock"] - - # Get POs for this supplier - supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {} - supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0") - - # Get CD adjustments for this supplier - supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()} - supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0")) - - total_spent += actual_spent - total_po_ordered += supplier_po_total - remaining = allocated - actual_spent - supplier_cd_total - supplier_po_total - - # Determine status - if remaining < 0: - status = "over" - elif remaining < allocated * Decimal("0.1"): # Less than 10% remaining - status = "on_track" - else: - status = "under" - - supplier_rows.append(SupplierBudgetRow( - supplier_id=supplier_id, - supplier_name=supplier_name, - historical_pct=hist_pct, - allocated_budget=allocated, - invoices_by_date=dict(invoices_by_date), - purchase_orders_by_date=supplier_po_dates, - actual_spent=actual_spent, - cd_adjustments_by_date=supplier_cd_dates, - cd_total=float(supplier_cd_total), - po_ordered=supplier_po_total, - remaining=remaining, - status=status, - )) - - # Add any suppliers with invoices this week that weren't in historical data - for key, invoices in weekly_invoices.items(): - if key not in processed_suppliers: - supplier_id, supplier_name = key - if supplier_id: - processed_po_suppliers.add(supplier_id) - all_supplier_names.append(supplier_name) - - invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list) - actual_spent = Decimal("0") - - for inv in invoices: - date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else "" - if date_str: - invoices_by_date[date_str].append(BudgetInvoice( - id=inv["id"], - invoice_number=inv["invoice_number"], - invoice_date=inv["invoice_date"], - net_stock=inv["net_stock"], - document_type=inv["document_type"], - )) - actual_spent += inv["net_stock"] - - supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {} - supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0") - - supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()} - supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0")) - - total_spent += actual_spent - total_po_ordered += supplier_po_total - combined = actual_spent + supplier_cd_total + supplier_po_total - - supplier_rows.append(SupplierBudgetRow( - supplier_id=supplier_id, - supplier_name=supplier_name, - historical_pct=Decimal("0"), # No historical data - allocated_budget=Decimal("0"), - invoices_by_date=dict(invoices_by_date), - purchase_orders_by_date=supplier_po_dates, - actual_spent=actual_spent, - cd_adjustments_by_date=supplier_cd_dates, - cd_total=float(supplier_cd_total), - po_ordered=supplier_po_total, - remaining=-combined, # Over by definition - status="over" if combined > 0 else "under", - )) - - # Add suppliers with POs but no invoices and no historical data - for sid, po_dates in po_by_supplier.items(): - if sid not in processed_po_suppliers: - sname = po_supplier_names.get(sid, f"Supplier #{sid}") - all_supplier_names.append(sname) - supplier_po_total = po_totals_by_supplier.get(sid, Decimal("0")) - total_po_ordered += supplier_po_total - - supplier_rows.append(SupplierBudgetRow( - supplier_id=sid, - supplier_name=sname, - historical_pct=Decimal("0"), - allocated_budget=Decimal("0"), - invoices_by_date={}, - purchase_orders_by_date=dict(po_dates), - actual_spent=Decimal("0"), - po_ordered=supplier_po_total, - remaining=-supplier_po_total, - status="over" if supplier_po_total > 0 else "under", - )) - - # Build daily breakdown with both historical and revenue-based budgets - daily_data = [] - daily_totals: dict[str, Decimal] = {} - cumulative_budget = Decimal("0") - cumulative_spent = Decimal("0") - - # Extract daily revenue from forecast API data - daily_forecast_revenue: dict[str, Decimal] = {} - total_forecast_rev = Decimal("0") - - if forecast_data: - # Use actual daily forecast from API - for day in forecast_data: - date_str = day.get("date") - if date_str: - dry = day.get("dry", {}) - dry_forecast = Decimal(str(dry.get("forecast", 0) or 0)) - daily_forecast_revenue[date_str] = dry_forecast - total_forecast_rev += dry_forecast - else: - # Fallback: distribute total forecast using historical patterns - for d in week_dates: - weekday = d.weekday() - day_pct = daily_distribution.get(weekday, Decimal("14.29")) - day_forecast_rev = (forecast_revenue * day_pct / 100).quantize(Decimal("0.01")) - daily_forecast_revenue[d.isoformat()] = day_forecast_rev - total_forecast_rev += day_forecast_rev - - # Apply adjusted daily revenue from overrides - if adjusted_daily: - for adj_date_str, adj_rev in adjusted_daily.items(): - daily_forecast_revenue[adj_date_str] = adj_rev - total_forecast_rev = sum(daily_forecast_revenue.values()) - - for d in week_dates: - date_str = d.isoformat() - day_name = d.strftime("%a") - weekday = d.weekday() # 0=Monday, 6=Sunday - - # Historical spend-based budget - budget_split_pct = daily_distribution.get(weekday, Decimal("14.29")) - historical_budget = (total_budget * budget_split_pct / 100).quantize(Decimal("0.01")) - - # Revenue-based budget (proportional to actual daily forecast revenue) - day_forecast_rev = daily_forecast_revenue.get(date_str, Decimal("0")) - if total_forecast_rev > 0: - revenue_pct = (day_forecast_rev / total_forecast_rev * 100).quantize(Decimal("0.01")) - revenue_budget = (total_budget * revenue_pct / 100).quantize(Decimal("0.01")) - else: - revenue_budget = (total_budget / 7).quantize(Decimal("0.01")) - - # Get actual spend for this day - day_spent = Decimal("0") - for supplier_row in supplier_rows: - for inv in supplier_row.invoices_by_date.get(date_str, []): - day_spent += inv.net_stock - - daily_totals[date_str] = day_spent - cumulative_budget += historical_budget # Use historical for cumulative - - # Only show cumulative spent for past/today - if d <= today: - cumulative_spent += day_spent - daily_data.append(DailyBudgetData( - date=d, - day_name=day_name, - forecast_revenue=day_forecast_rev, - budget_split_pct=budget_split_pct, - historical_budget=historical_budget, - revenue_budget=revenue_budget, - actual_spent=day_spent, - cumulative_budget=cumulative_budget, - cumulative_spent=cumulative_spent, - )) - else: - daily_data.append(DailyBudgetData( - date=d, - day_name=day_name, - forecast_revenue=day_forecast_rev, - budget_split_pct=budget_split_pct, - historical_budget=historical_budget, - revenue_budget=revenue_budget, - actual_spent=None, - cumulative_budget=cumulative_budget, - cumulative_spent=None, - )) - - # Apply pre-computed cost distribution adjustments to daily totals and total_spent - for date_str, cd_amount in cd_daily_amounts.items(): - if date_str in daily_totals: - daily_totals[date_str] += cd_amount - else: - daily_totals[date_str] = cd_amount - total_spent += cd_total_adjustment - - total_remaining = total_budget - total_spent - total_po_ordered - - return WeeklyBudgetResponse( - week_start=week_start, - week_end=week_end, - dates=week_dates, - otb_revenue=otb_revenue, - forecast_revenue=forecast_revenue, - forecast_source=forecast_source, - forecast_summary=forecast_summary, - has_overrides=has_overrides_flag, - snapshot_revenue=snapshot_revenue_val, - adjusted_revenue=adjusted_revenue_val, - gp_target_pct=gp_target, - min_budget=min_budget, - total_budget=total_budget, - total_spent=total_spent, - total_po_ordered=total_po_ordered, - total_remaining=total_remaining, - cd_budget_reservation=Decimal("0"), # Not used; CD is attributed per-supplier - suppliers=supplier_rows, - all_supplier_names=all_supplier_names, - daily_data=daily_data, - daily_totals=daily_totals, - ) - - -@router.get("/settings", response_model=BudgetSettingsResponse) -async def get_budget_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get current budget settings""" - settings = await get_settings(db, current_user.kitchen_id) - - return BudgetSettingsResponse( - forecast_api_url=settings.forecast_api_url, - forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key), - budget_gp_target=settings.budget_gp_target or Decimal("65.00"), - budget_lookback_weeks=settings.budget_lookback_weeks or 4, - ) - - -@router.patch("/settings", response_model=BudgetSettingsResponse) -async def update_budget_settings( - updates: BudgetSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update budget settings""" - settings = await get_settings(db, current_user.kitchen_id) - - if updates.forecast_api_url is not None: - settings.forecast_api_url = updates.forecast_api_url or None - - if updates.forecast_api_key is not None: - settings.forecast_api_key = updates.forecast_api_key or None - - if updates.budget_gp_target is not None: - settings.budget_gp_target = updates.budget_gp_target - - if updates.budget_lookback_weeks is not None: - settings.budget_lookback_weeks = updates.budget_lookback_weeks - - await db.commit() - await db.refresh(settings) - - return BudgetSettingsResponse( - forecast_api_url=settings.forecast_api_url, - forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key), - budget_gp_target=settings.budget_gp_target or Decimal("65.00"), - budget_lookback_weeks=settings.budget_lookback_weeks or 4, - ) - - -@router.post("/test-forecast-connection", response_model=TestConnectionResponse) -async def test_forecast_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test connection to forecast API""" - settings = await get_settings(db, current_user.kitchen_id) - - if not settings.forecast_api_url: - return TestConnectionResponse( - success=False, - message="Forecast API URL not configured" - ) - - if not settings.forecast_api_key: - return TestConnectionResponse( - success=False, - message="Forecast API key not configured" - ) - - try: - async with ForecastAPIClient( - settings.forecast_api_url, - settings.forecast_api_key - ) as client: - success, message = await client.test_connection() - return TestConnectionResponse(success=success, message=message) - except Exception as e: - return TestConnectionResponse( - success=False, - message=f"Connection failed: {str(e)}" - ) diff --git a/backend/api/calendar_events.py b/backend/api/calendar_events.py deleted file mode 100644 index 2461420..0000000 --- a/backend/api/calendar_events.py +++ /dev/null @@ -1,236 +0,0 @@ -from datetime import date, timedelta -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.calendar_events import CalendarEvent -from auth import get_current_user, require_cap - -router = APIRouter() - - -# Pydantic schemas -class CalendarEventCreate(BaseModel): - event_date: date - event_type: str # reminder, event, note - title: str - description: str | None = None - - -class CalendarEventUpdate(BaseModel): - event_date: date | None = None - event_type: str | None = None - title: str | None = None - description: str | None = None - - -class CalendarEventResponse(BaseModel): - id: int - event_date: date - event_type: str - title: str - description: str | None - created_at: str - - class Config: - from_attributes = True - - -# Endpoints -@router.get("/") -async def list_events( - from_date: Optional[date] = None, - to_date: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> list[CalendarEventResponse]: - """List events for date range""" - query = select(CalendarEvent).where( - CalendarEvent.kitchen_id == current_user.kitchen_id - ) - - if from_date: - query = query.where(CalendarEvent.event_date >= from_date) - if to_date: - query = query.where(CalendarEvent.event_date <= to_date) - - query = query.order_by(CalendarEvent.event_date, CalendarEvent.created_at) - result = await db.execute(query) - events = result.scalars().all() - - return [ - CalendarEventResponse( - id=e.id, - event_date=e.event_date, - event_type=e.event_type, - title=e.title, - description=e.description, - created_at=e.created_at.isoformat() - ) - for e in events - ] - - -@router.get("/{date}") -async def get_events_for_date( - date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> list[CalendarEventResponse]: - """Get all events for a specific date""" - result = await db.execute( - select(CalendarEvent).where( - and_( - CalendarEvent.kitchen_id == current_user.kitchen_id, - CalendarEvent.event_date == date - ) - ).order_by(CalendarEvent.created_at) - ) - events = result.scalars().all() - - return [ - CalendarEventResponse( - id=e.id, - event_date=e.event_date, - event_type=e.event_type, - title=e.title, - description=e.description, - created_at=e.created_at.isoformat() - ) - for e in events - ] - - -@router.post("/") -async def create_event( - event: CalendarEventCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> CalendarEventResponse: - """Create new event""" - new_event = CalendarEvent( - kitchen_id=current_user.kitchen_id, - event_date=event.event_date, - event_type=event.event_type, - title=event.title, - description=event.description, - created_by=current_user.id - ) - db.add(new_event) - await db.commit() - await db.refresh(new_event) - - return CalendarEventResponse( - id=new_event.id, - event_date=new_event.event_date, - event_type=new_event.event_type, - title=new_event.title, - description=new_event.description, - created_at=new_event.created_at.isoformat() - ) - - -@router.put("/{id}") -async def update_event( - id: int, - update: CalendarEventUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update existing event""" - result = await db.execute( - select(CalendarEvent).where( - and_( - CalendarEvent.id == id, - CalendarEvent.kitchen_id == current_user.kitchen_id - ) - ) - ) - event = result.scalar_one_or_none() - - if not event: - raise HTTPException(status_code=404, detail="Event not found") - - if update.event_date is not None: - event.event_date = update.event_date - if update.event_type is not None: - event.event_type = update.event_type - if update.title is not None: - event.title = update.title - if update.description is not None: - event.description = update.description - - await db.commit() - await db.refresh(event) - - return CalendarEventResponse( - id=event.id, - event_date=event.event_date, - event_type=event.event_type, - title=event.title, - description=event.description, - created_at=event.created_at.isoformat() - ) - - -@router.delete("/{id}") -async def delete_event( - id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete event""" - result = await db.execute( - select(CalendarEvent).where( - and_( - CalendarEvent.id == id, - CalendarEvent.kitchen_id == current_user.kitchen_id - ) - ) - ) - event = result.scalar_one_or_none() - - if not event: - raise HTTPException(status_code=404, detail="Event not found") - - await db.delete(event) - await db.commit() - - return {"message": "Event deleted"} - - -@router.get("/dashboard/upcoming") -async def get_upcoming_events( - limit: int = 3, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get next N upcoming events for dashboard widget""" - today = date.today() - - result = await db.execute( - select(CalendarEvent).where( - and_( - CalendarEvent.kitchen_id == current_user.kitchen_id, - CalendarEvent.event_date >= today - ) - ).order_by(CalendarEvent.event_date, CalendarEvent.created_at).limit(limit) - ) - events = result.scalars().all() - - return { - "total_count": len(events), - "upcoming_events": [ - { - "id": e.id, - "event_date": e.event_date.isoformat(), - "event_type": e.event_type, - "title": e.title - } - for e in events - ] - } diff --git a/backend/api/cost_distributions.py b/backend/api/cost_distributions.py deleted file mode 100644 index 4ff295c..0000000 --- a/backend/api/cost_distributions.py +++ /dev/null @@ -1,863 +0,0 @@ -""" -Cost Distribution API endpoints — create, view, settle early, cancel, -and weekly summaries for budget page integration. -""" -import logging -from datetime import date, timedelta -from decimal import Decimal, ROUND_HALF_UP -from typing import Optional -from collections import defaultdict - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, delete -from sqlalchemy.orm import selectinload -from pydantic import BaseModel, field_serializer - -from database import get_db -from models.user import User -from models.invoice import Invoice, InvoiceStatus -from models.line_item import LineItem -from models.supplier import Supplier -from models.settings import KitchenSettings -from models.cost_distribution import ( - CostDistribution, - CostDistributionLineSelection, - CostDistributionEntry, - DistributionStatus, - DistributionMethod, -) -from auth import get_current_user, require_cap - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# ── Pydantic schemas ────────────────────────────────────────────────────────── - -class LineSelectionIn(BaseModel): - line_item_id: int - selected_quantity: Decimal - - -class CostDistributionCreate(BaseModel): - invoice_id: int - method: str # "OFFSET" or "DISTRIBUTE" - notes: Optional[str] = None - line_selections: list[LineSelectionIn] - # For OFFSET - target_date: Optional[date] = None - # For DISTRIBUTE - days_of_week: Optional[list[int]] = None # 0=Mon, 6=Sun (Python weekday()) - num_weeks: Optional[int] = None - start_date: Optional[date] = None - - -class CostDistributionUpdate(BaseModel): - notes: Optional[str] = None - - -class SettleEarlyRequest(BaseModel): - entry_date: date - amount: Optional[Decimal] = None # null = settle all - - -# ── Response schemas ────────────────────────────────────────────────────────── - -class LineSelectionOut(BaseModel): - id: int - line_item_id: int - description: Optional[str] = None - original_quantity: Optional[Decimal] = None - selected_quantity: Decimal - unit_price: Decimal - distributed_value: Decimal - - @field_serializer('selected_quantity', 'unit_price', 'distributed_value', 'original_quantity') - def ser(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - -class EntryOut(BaseModel): - id: int - entry_date: date - amount: Decimal - is_source_offset: bool - is_overpay: bool - - @field_serializer('amount') - def ser_amount(self, v: Decimal) -> float: - return float(v) - - -class CostDistributionOut(BaseModel): - id: int - invoice_id: int - invoice_number: Optional[str] = None - invoice_date: Optional[date] = None - supplier_name: Optional[str] = None - status: str - method: str - notes: Optional[str] - total_distributed_value: Decimal - remaining_balance: Decimal - source_date: date - created_by_name: Optional[str] = None - created_at: str - line_selections: list[LineSelectionOut] = [] - entries: list[EntryOut] = [] - - @field_serializer('total_distributed_value', 'remaining_balance') - def ser_dec(self, v: Decimal) -> float: - return float(v) - - -class LineItemAvailability(BaseModel): - id: int - description: Optional[str] - unit: Optional[str] - quantity: Optional[Decimal] - unit_price: Optional[Decimal] - amount: Optional[Decimal] - is_non_stock: bool - already_distributed_qty: Decimal - available_qty: Decimal - - @field_serializer('quantity', 'unit_price', 'amount', 'already_distributed_qty', 'available_qty') - def ser(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - -class InvoiceAvailabilityOut(BaseModel): - invoice_id: int - invoice_number: Optional[str] - invoice_date: Optional[date] - supplier_name: Optional[str] - line_items: list[LineItemAvailability] = [] - - -class WeeklyDistributionRow(BaseModel): - distribution_id: int - title: str # Compact: "dd/mm/yy - Supplier" - supplier_name: Optional[str] = None - invoice_number: Optional[str] = None - source_date_str: str = "" # dd/mm/yy - summary: str = "" # e.g. "£200 laid away to 28/02/26" or "£200 distributed over 4 weeks" - notes: Optional[str] = None - invoice_id: int - entries_by_date: dict[str, float] - total_distributed_value: float - remaining_balance: float - bf_balance: float # Outstanding at start of this week - cf_balance: float # Outstanding at end of this week - status: str - - -class WeeklyDistributionsOut(BaseModel): - week_start: date - week_end: date - distributions: list[WeeklyDistributionRow] = [] - daily_totals: dict[str, float] = {} - bf_balance: float - cf_balance: float - week_total: float - - -# ── Helper functions ────────────────────────────────────────────────────────── - -def _generate_target_dates( - method: str, - target_date: Optional[date], - days_of_week: Optional[list[int]], - num_weeks: Optional[int], - start_date: Optional[date], -) -> list[date]: - """Generate target dates based on distribution method.""" - if method == DistributionMethod.OFFSET.value: - if not target_date: - raise HTTPException(400, "target_date required for OFFSET method") - return [target_date] - - if method == DistributionMethod.DISTRIBUTE.value: - if not days_of_week or not num_weeks or not start_date: - raise HTTPException(400, "days_of_week, num_weeks, and start_date required for DISTRIBUTE method") - if not days_of_week: - raise HTTPException(400, "At least one day of week must be selected") - if num_weeks < 1: - raise HTTPException(400, "num_weeks must be at least 1") - - dates = [] - for week in range(num_weeks): - week_start = start_date + timedelta(weeks=week) - for day_offset in range(7): - d = week_start + timedelta(days=day_offset) - if d.weekday() in days_of_week: - dates.append(d) - - # Deduplicate and sort (in case start_date is mid-week) - dates = sorted(set(dates)) - if not dates: - raise HTTPException(400, "No target dates generated from the selected days and weeks") - return dates - - raise HTTPException(400, f"Invalid method: {method}") - - -def _distribute_amount(total: Decimal, count: int) -> list[Decimal]: - """Distribute a total amount evenly across count entries. Last entry absorbs rounding.""" - if count == 0: - return [] - per_entry = (total / count).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - amounts = [per_entry] * count - # Adjust last entry to absorb rounding difference - distributed_sum = per_entry * (count - 1) - amounts[-1] = total - distributed_sum - return amounts - - -async def _get_already_distributed_qty( - db: AsyncSession, line_item_id: int, exclude_distribution_id: Optional[int] = None -) -> Decimal: - """Get total quantity already distributed for a line item from ACTIVE distributions.""" - query = ( - select(func.coalesce(func.sum(CostDistributionLineSelection.selected_quantity), 0)) - .join(CostDistribution, CostDistributionLineSelection.distribution_id == CostDistribution.id) - .where( - CostDistributionLineSelection.line_item_id == line_item_id, - CostDistribution.status == DistributionStatus.ACTIVE.value, - ) - ) - if exclude_distribution_id: - query = query.where(CostDistribution.id != exclude_distribution_id) - result = await db.execute(query) - return Decimal(str(result.scalar() or 0)) - - -# ── Endpoints ───────────────────────────────────────────────────────────────── - -@router.get("/invoice/{invoice_id}/availability", response_model=InvoiceAvailabilityOut) -async def get_invoice_availability( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get line items available for distribution from an invoice.""" - invoice = await db.execute( - select(Invoice) - .options(selectinload(Invoice.line_items)) - .where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id, - ) - ) - invoice = invoice.scalar_one_or_none() - if not invoice: - raise HTTPException(404, "Invoice not found") - if invoice.status != InvoiceStatus.CONFIRMED: - raise HTTPException(400, "Only CONFIRMED invoices can be distributed") - if invoice.document_type == "credit_note": - raise HTTPException(400, "Credit notes cannot be distributed") - - supplier_name = None - if invoice.supplier_id: - supplier = await db.execute( - select(Supplier).where(Supplier.id == invoice.supplier_id) - ) - supplier = supplier.scalar_one_or_none() - if supplier: - supplier_name = supplier.name - - items = [] - for li in invoice.line_items: - already = await _get_already_distributed_qty(db, li.id) - original_qty = li.quantity or Decimal("0") - available = max(Decimal("0"), original_qty - already) - items.append(LineItemAvailability( - id=li.id, - description=li.description, - unit=li.unit, - quantity=li.quantity, - unit_price=li.unit_price, - amount=li.amount, - is_non_stock=li.is_non_stock or False, - already_distributed_qty=already, - available_qty=available, - )) - - return InvoiceAvailabilityOut( - invoice_id=invoice.id, - invoice_number=invoice.invoice_number, - invoice_date=invoice.invoice_date, - supplier_name=supplier_name or invoice.vendor_name, - line_items=items, - ) - - -@router.post("/", response_model=CostDistributionOut) -async def create_cost_distribution( - data: CostDistributionCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Create a new cost distribution.""" - # Validate invoice - invoice = await db.execute( - select(Invoice) - .options(selectinload(Invoice.line_items)) - .where( - Invoice.id == data.invoice_id, - Invoice.kitchen_id == current_user.kitchen_id, - ) - ) - invoice = invoice.scalar_one_or_none() - if not invoice: - raise HTTPException(404, "Invoice not found") - if invoice.status != InvoiceStatus.CONFIRMED: - raise HTTPException(400, "Only CONFIRMED invoices can be distributed") - if invoice.document_type == "credit_note": - raise HTTPException(400, "Credit notes cannot be distributed") - if not invoice.invoice_date: - raise HTTPException(400, "Invoice must have a date to be distributed") - - # Validate method - if data.method not in [m.value for m in DistributionMethod]: - raise HTTPException(400, f"Invalid method: {data.method}") - - # Get settings for max_days validation - settings = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings.scalar_one_or_none() - max_days = settings.cost_distribution_max_days if settings else 90 - - # Generate target dates - target_dates = _generate_target_dates( - data.method, data.target_date, data.days_of_week, data.num_weeks, data.start_date - ) - - # Validate max days - today = date.today() - max_allowed_date = today + timedelta(days=max_days) - for td in target_dates: - if td > max_allowed_date: - raise HTTPException( - 400, - f"Target date {td} exceeds maximum of {max_days} days into the future ({max_allowed_date})" - ) - - # Build line item map - line_item_map = {li.id: li for li in invoice.line_items} - - # Validate line selections and calculate totals - if not data.line_selections: - raise HTTPException(400, "At least one line item must be selected") - - total_distributed_value = Decimal("0") - selections_data = [] - - for sel in data.line_selections: - li = line_item_map.get(sel.line_item_id) - if not li: - raise HTTPException(400, f"Line item {sel.line_item_id} not found on invoice") - if li.is_non_stock: - raise HTTPException(400, f"Non-stock item '{li.description}' cannot be distributed") - if sel.selected_quantity <= 0: - raise HTTPException(400, "Selected quantity must be greater than 0") - - # Check available quantity - already = await _get_already_distributed_qty(db, li.id) - original_qty = li.quantity or Decimal("0") - available = original_qty - already - if sel.selected_quantity > available: - raise HTTPException( - 400, - f"Requested qty {sel.selected_quantity} exceeds available {available} for '{li.description}'" - ) - - unit_price = li.unit_price or Decimal("0") - distributed_value = (sel.selected_quantity * unit_price).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) - total_distributed_value += distributed_value - selections_data.append({ - "line_item_id": li.id, - "selected_quantity": sel.selected_quantity, - "unit_price": unit_price, - "distributed_value": distributed_value, - }) - - if total_distributed_value <= 0: - raise HTTPException(400, "Total distributed value must be greater than 0") - - # Create the distribution header - distribution = CostDistribution( - kitchen_id=current_user.kitchen_id, - invoice_id=invoice.id, - status=DistributionStatus.ACTIVE.value, - method=data.method, - notes=data.notes, - total_distributed_value=total_distributed_value, - remaining_balance=total_distributed_value, - source_date=invoice.invoice_date, - created_by=current_user.id, - ) - db.add(distribution) - await db.flush() # Get the distribution ID - - # Create line selections - for sel_data in selections_data: - selection = CostDistributionLineSelection( - distribution_id=distribution.id, - **sel_data, - ) - db.add(selection) - - # Create source offset entry (negative on invoice date) - source_entry = CostDistributionEntry( - distribution_id=distribution.id, - kitchen_id=current_user.kitchen_id, - entry_date=invoice.invoice_date, - amount=-total_distributed_value, - is_source_offset=True, - is_overpay=False, - ) - db.add(source_entry) - - # Create target entries - entry_amounts = _distribute_amount(total_distributed_value, len(target_dates)) - for td, amt in zip(target_dates, entry_amounts): - entry = CostDistributionEntry( - distribution_id=distribution.id, - kitchen_id=current_user.kitchen_id, - entry_date=td, - amount=amt, - is_source_offset=False, - is_overpay=False, - ) - db.add(entry) - - await db.commit() - await db.refresh(distribution) - - return await _build_distribution_response(db, distribution) - - -@router.get("/weekly", response_model=WeeklyDistributionsOut) -async def get_weekly_distributions( - week_start: date, - week_end: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get all cost distributions relevant to a week view for the budget page.""" - kitchen_id = current_user.kitchen_id - - # Get ALL active distributions (even if no entries this week) to prevent gaming - # Also include COMPLETED distributions that have entries in this week period - active_dists = await db.execute( - select(CostDistribution) - .options( - selectinload(CostDistribution.entries), - selectinload(CostDistribution.invoice), - ) - .where( - CostDistribution.kitchen_id == kitchen_id, - CostDistribution.status == DistributionStatus.ACTIVE.value, - ) - .order_by(CostDistribution.id) - ) - active_dists = active_dists.scalars().all() - - # Completed distributions that have entries within the viewed week - completed_dists = await db.execute( - select(CostDistribution) - .options( - selectinload(CostDistribution.entries), - selectinload(CostDistribution.invoice), - ) - .where( - CostDistribution.kitchen_id == kitchen_id, - CostDistribution.status == DistributionStatus.COMPLETED.value, - CostDistribution.id.in_( - select(CostDistributionEntry.distribution_id) - .where( - CostDistributionEntry.kitchen_id == kitchen_id, - CostDistributionEntry.entry_date >= week_start, - CostDistributionEntry.entry_date <= week_end, - ) - ), - ) - .order_by(CostDistribution.id) - ) - completed_dists = completed_dists.scalars().all() - - # Merge — active first, then completed with entries in period - seen_ids = {d.id for d in active_dists} - distributions = list(active_dists) - for d in completed_dists: - if d.id not in seen_ids: - distributions.append(d) - - rows = [] - daily_totals: dict[str, Decimal] = defaultdict(Decimal) - bf_balance = Decimal("0") - week_total = Decimal("0") - - for dist in distributions: - # Get supplier name - supplier_name = None - if dist.invoice: - if dist.invoice.supplier_id: - supplier = await db.execute( - select(Supplier).where(Supplier.id == dist.invoice.supplier_id) - ) - supplier = supplier.scalar_one_or_none() - if supplier: - supplier_name = supplier.name - if not supplier_name: - supplier_name = dist.invoice.vendor_name - - invoice_num = dist.invoice.invoice_number if dist.invoice else None - source_date_short = dist.source_date.strftime("%d/%m/%y") if dist.source_date else "" - title = f"{source_date_short} - {supplier_name or 'Unknown'}" - - # Build summary text - total_val = f"£{float(dist.total_distributed_value):.2f}" - target_entries = [e for e in dist.entries if not e.is_source_offset and not e.is_overpay] - if dist.method == "OFFSET" and target_entries: - target_date = target_entries[0].entry_date.strftime("%d/%m/%y") - summary = f"{total_val} laid away to {target_date}" - elif target_entries: - # DISTRIBUTE: count unique days of week and number of weeks - target_dates = sorted(set(e.entry_date for e in target_entries)) - if len(target_dates) > 1: - first, last = target_dates[0], target_dates[-1] - num_weeks = max(1, ((last - first).days // 7) + 1) - dow_set = set(d.strftime("%a") for d in target_dates) - days_str = ",".join(sorted(dow_set, key=lambda x: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].index(x))) - summary = f"{total_val} distributed {days_str} over {num_weeks} weeks" - else: - summary = f"{total_val} distributed to {target_dates[0].strftime('%d/%m/%y')}" - else: - summary = f"{total_val} distributed" - - # Get entries for this week (including source offset so user sees the deduction) - entries_by_date: dict[str, float] = {} - for entry in dist.entries: - if week_start <= entry.entry_date <= week_end: - date_key = entry.entry_date.isoformat() - entries_by_date[date_key] = entries_by_date.get(date_key, 0) + float(entry.amount) - daily_totals[date_key] += entry.amount - week_total += entry.amount - - # Per-distribution BF/CF as net running balance: - # BF = sum of ALL entries (including source offset) before this week - # CF = BF + sum of ALL entries within this week - # e.g. new distribution: BF=0, source deducts -£200, CF=-£200 - # next week: BF=-£200, positives bring it toward 0 - dist_bf = sum( - e.amount for e in dist.entries - if e.entry_date < week_start - ) - entries_this_week = sum( - e.amount for e in dist.entries - if week_start <= e.entry_date <= week_end - ) - dist_cf = dist_bf + entries_this_week - - # Only include distributions that are relevant to this period: - # has a non-zero BF or CF, or has entries in this week - if dist_bf == 0 and dist_cf == 0 and not entries_by_date: - continue - - bf_balance += dist_bf - - rows.append(WeeklyDistributionRow( - distribution_id=dist.id, - title=title, - supplier_name=supplier_name, - invoice_number=invoice_num, - source_date_str=source_date_short, - summary=summary, - notes=dist.notes, - invoice_id=dist.invoice_id, - entries_by_date=entries_by_date, - total_distributed_value=float(dist.total_distributed_value), - remaining_balance=float(dist.remaining_balance), - bf_balance=float(dist_bf), - cf_balance=float(dist_cf), - status=dist.status, - )) - - cf_balance = bf_balance + week_total - - return WeeklyDistributionsOut( - week_start=week_start, - week_end=week_end, - distributions=rows, - daily_totals={k: float(v) for k, v in daily_totals.items()}, - bf_balance=float(bf_balance), - cf_balance=float(cf_balance), - week_total=float(week_total), - ) - - -@router.get("/{distribution_id}", response_model=CostDistributionOut) -async def get_cost_distribution( - distribution_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get a single cost distribution with line selections and entries.""" - distribution = await db.execute( - select(CostDistribution) - .options( - selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item), - selectinload(CostDistribution.entries), - ) - .where( - CostDistribution.id == distribution_id, - CostDistribution.kitchen_id == current_user.kitchen_id, - ) - ) - distribution = distribution.scalar_one_or_none() - if not distribution: - raise HTTPException(404, "Cost distribution not found") - - return await _build_distribution_response(db, distribution) - - -@router.put("/{distribution_id}", response_model=CostDistributionOut) -async def update_cost_distribution( - distribution_id: int, - data: CostDistributionUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Update notes on an existing distribution.""" - distribution = await db.execute( - select(CostDistribution) - .options( - selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item), - selectinload(CostDistribution.entries), - ) - .where( - CostDistribution.id == distribution_id, - CostDistribution.kitchen_id == current_user.kitchen_id, - ) - ) - distribution = distribution.scalar_one_or_none() - if not distribution: - raise HTTPException(404, "Cost distribution not found") - if distribution.status != DistributionStatus.ACTIVE.value: - raise HTTPException(400, "Can only update ACTIVE distributions") - - if data.notes is not None: - distribution.notes = data.notes - - await db.commit() - await db.refresh(distribution) - - return await _build_distribution_response(db, distribution) - - -@router.delete("/{distribution_id}") -async def cancel_cost_distribution( - distribution_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Cancel a cost distribution. Non-admin restricted to distributions - where the source (invoice) date is within 14 days.""" - distribution = await db.execute( - select(CostDistribution).where( - CostDistribution.id == distribution_id, - CostDistribution.kitchen_id == current_user.kitchen_id, - ) - ) - distribution = distribution.scalar_one_or_none() - if not distribution: - raise HTTPException(404, "Cost distribution not found") - if distribution.status == DistributionStatus.CANCELLED.value: - raise HTTPException(400, "Distribution is already cancelled") - - # Anti-gaming: non-admin cannot cancel distributions where the source - # date is more than 14 days in the past (cost would silently revert to - # the old invoice date and could be overlooked) - if not current_user.is_admin: - min_allowed_date = date.today() - timedelta(days=14) - if distribution.source_date < min_allowed_date: - raise HTTPException( - 400, - f"Cannot cancel — invoice date {distribution.source_date} is more than 14 days ago. Ask an admin." - ) - - from datetime import datetime - distribution.status = DistributionStatus.CANCELLED.value - distribution.cancelled_by = current_user.id - distribution.cancelled_at = datetime.utcnow() - - await db.commit() - return {"message": "Cost distribution cancelled", "id": distribution_id} - - -@router.post("/{distribution_id}/settle-early", response_model=CostDistributionOut) -async def settle_early( - distribution_id: int, - data: SettleEarlyRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Settle a distribution early by moving remaining balance to a chosen date.""" - distribution = await db.execute( - select(CostDistribution) - .options( - selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item), - selectinload(CostDistribution.entries), - ) - .where( - CostDistribution.id == distribution_id, - CostDistribution.kitchen_id == current_user.kitchen_id, - ) - ) - distribution = distribution.scalar_one_or_none() - if not distribution: - raise HTTPException(404, "Cost distribution not found") - if distribution.status != DistributionStatus.ACTIVE.value: - raise HTTPException(400, "Can only settle ACTIVE distributions") - - today = date.today() - - # Anti-gaming: non-admin cannot settle more than 14 days in the past - if not current_user.is_admin: - min_allowed_date = today - timedelta(days=14) - if data.entry_date < min_allowed_date: - raise HTTPException( - 400, - f"Cannot settle more than 14 days in the past. Earliest allowed: {min_allowed_date}" - ) - - # Calculate entries from the settle date onwards — these get replaced by the single - # settle entry. Using entry_date (not today) so that settling for yesterday correctly - # consolidates yesterday's entry + all future entries into the chosen date. - settable_entries = [ - e for e in distribution.entries - if not e.is_source_offset and not e.is_overpay and e.entry_date >= data.entry_date - ] - settable_total = sum(e.amount for e in settable_entries) - - if settable_total <= 0: - raise HTTPException(400, "No entries to settle from the chosen date onwards") - - settle_amount = data.amount if data.amount is not None else settable_total - if settle_amount <= 0: - raise HTTPException(400, "Settle amount must be greater than 0") - if settle_amount > settable_total: - raise HTTPException(400, f"Settle amount {settle_amount} exceeds settable entries total {settable_total}") - - # Delete all entries from the settle date onwards - for entry in settable_entries: - await db.delete(entry) - - # Create settle entry on the chosen date - overpay_entry = CostDistributionEntry( - distribution_id=distribution.id, - kitchen_id=current_user.kitchen_id, - entry_date=data.entry_date, - amount=settle_amount, - is_source_offset=False, - is_overpay=True, - ) - db.add(overpay_entry) - - # Update remaining_balance: what's left unaccounted - new_remaining = settable_total - settle_amount - distribution.remaining_balance = new_remaining - - # If fully settled, mark as completed - if new_remaining <= 0: - distribution.status = DistributionStatus.COMPLETED.value - distribution.remaining_balance = Decimal("0") - - await db.commit() - await db.refresh(distribution) - - return await _build_distribution_response(db, distribution) - - -# ── Response builder helper ─────────────────────────────────────────────────── - -async def _build_distribution_response( - db: AsyncSession, distribution: CostDistribution -) -> CostDistributionOut: - """Build the full response object for a cost distribution.""" - # Get invoice info - invoice = await db.execute( - select(Invoice).where(Invoice.id == distribution.invoice_id) - ) - invoice = invoice.scalar_one_or_none() - - supplier_name = None - if invoice and invoice.supplier_id: - supplier = await db.execute( - select(Supplier).where(Supplier.id == invoice.supplier_id) - ) - supplier = supplier.scalar_one_or_none() - if supplier: - supplier_name = supplier.name - if not supplier_name and invoice: - supplier_name = invoice.vendor_name - - # Get creator name - creator = await db.execute( - select(User).where(User.id == distribution.created_by) - ) - creator = creator.scalar_one_or_none() - - # Build line selections with line item details - line_selections = [] - for sel in distribution.line_selections: - li = sel.line_item if hasattr(sel, 'line_item') and sel.line_item else None - if not li: - li_result = await db.execute(select(LineItem).where(LineItem.id == sel.line_item_id)) - li = li_result.scalar_one_or_none() - - line_selections.append(LineSelectionOut( - id=sel.id, - line_item_id=sel.line_item_id, - description=li.description if li else None, - original_quantity=li.quantity if li else None, - selected_quantity=sel.selected_quantity, - unit_price=sel.unit_price, - distributed_value=sel.distributed_value, - )) - - entries = [ - EntryOut( - id=e.id, - entry_date=e.entry_date, - amount=e.amount, - is_source_offset=e.is_source_offset, - is_overpay=e.is_overpay, - ) - for e in distribution.entries - ] - - return CostDistributionOut( - id=distribution.id, - invoice_id=distribution.invoice_id, - invoice_number=invoice.invoice_number if invoice else None, - invoice_date=invoice.invoice_date if invoice else None, - supplier_name=supplier_name, - status=distribution.status, - method=distribution.method, - notes=distribution.notes, - total_distributed_value=distribution.total_distributed_value, - remaining_balance=distribution.remaining_balance, - source_date=distribution.source_date, - created_by_name=creator.name if creator else None, - created_at=distribution.created_at.isoformat() if distribution.created_at else "", - line_selections=line_selections, - entries=entries, - ) diff --git a/backend/api/cover_overrides.py b/backend/api/cover_overrides.py deleted file mode 100644 index f123e73..0000000 --- a/backend/api/cover_overrides.py +++ /dev/null @@ -1,648 +0,0 @@ -""" -Cover Override API endpoints. - -Handles forecast snapshots, cover overrides, and spend rate overrides -for the Spend Budget feature. -""" -from datetime import date, timedelta -from decimal import Decimal -from typing import Optional -import logging - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import text, select, delete -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from auth import get_current_user, require_cap -from services.forecast_api import ForecastAPIClient, ForecastAPIError - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# --- Request/Response Models --- - -class SnapshotRequest(BaseModel): - week_offset: int = 0 - - -class CoverOverrideRequest(BaseModel): - override_date: str # YYYY-MM-DD - period: str # 'lunch' or 'dinner' - override_covers: int # target total covers - - -class SpendRateOverrideRequest(BaseModel): - week_offset: int = 0 - period: str # 'breakfast', 'lunch', or 'dinner' - food_spend: Optional[float] = None - drinks_spend: Optional[float] = None - - -class SnapshotData(BaseModel): - date: str - period: str - forecast_covers: int - otb_covers: int - food_spend: Optional[float] - drinks_spend: Optional[float] - forecast_dry_revenue: Optional[float] - - -class OverrideData(BaseModel): - id: int - override_date: str - period: str - override_covers: int - original_forecast: Optional[int] - original_otb: Optional[int] - - -class SpendRateData(BaseModel): - period: str - food_spend_api: Optional[float] - drinks_spend_api: Optional[float] - food_spend_snapshot: Optional[float] - drinks_spend_snapshot: Optional[float] - food_spend_override: Optional[float] - drinks_spend_override: Optional[float] - food_spend_effective: float - drinks_spend_effective: float - - -class RecalcDay(BaseModel): - date: str - day_name: str - is_past: bool - periods: dict # period -> {actual, otb, pickup, effective, override, snapshot, variance} - day_revenue: float - - -class WeeklyOverrideResponse(BaseModel): - week_start: str - week_end: str - has_snapshot: bool - vat_rate: float = 1.20 - snapshot_revenue: Optional[float] = None - adjusted_revenue: Optional[float] = None - snapshots: list[SnapshotData] = [] - overrides: list[OverrideData] = [] - spend_rates: list[SpendRateData] = [] - recalc_days: list[RecalcDay] = [] - - -# --- Helpers --- - -def get_week_dates(week_offset: int = 0) -> tuple[date, date, list[date]]: - today = date.today() - current_monday = today - timedelta(days=today.weekday()) - week_start = current_monday + timedelta(weeks=week_offset) - week_end = week_start + timedelta(days=6) - week_dates = [week_start + timedelta(days=i) for i in range(7)] - return week_start, week_end, week_dates - - -async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings: - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id) - ) - settings = result.scalar_one_or_none() - if not settings: - raise HTTPException(status_code=404, detail="Kitchen settings not found") - return settings - - -# --- Endpoints --- - -@router.get("/weekly", response_model=WeeklyOverrideResponse) -async def get_weekly_overrides( - week_offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get snapshot, overrides, spend rates, and recalculated breakdown for a week.""" - week_start, week_end, week_dates = get_week_dates(week_offset) - kitchen_id = current_user.kitchen_id - today = date.today() - - # Check if snapshot exists - snap_result = await db.execute(text(""" - SELECT snapshot_date, period, forecast_covers, otb_covers, - food_spend, drinks_spend, forecast_dry_revenue - FROM forecast_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - ORDER BY snapshot_date, period - """), {"kid": kitchen_id, "ws": week_start}) - snap_rows = snap_result.fetchall() - - has_snapshot = len(snap_rows) > 0 - snapshots = [] - snap_lookup = {} # (date_str, period) -> row - - for row in snap_rows: - date_str = row.snapshot_date.isoformat() if hasattr(row.snapshot_date, 'isoformat') else str(row.snapshot_date) - snapshots.append(SnapshotData( - date=date_str, - period=row.period, - forecast_covers=row.forecast_covers, - otb_covers=row.otb_covers, - food_spend=float(row.food_spend) if row.food_spend else None, - drinks_spend=float(row.drinks_spend) if row.drinks_spend else None, - forecast_dry_revenue=float(row.forecast_dry_revenue) if row.forecast_dry_revenue else None, - )) - snap_lookup[(date_str, row.period)] = row - - # Get week snapshot for revenue totals - week_snap_result = await db.execute(text(""" - SELECT total_forecast_revenue, total_otb_revenue, gp_target - FROM forecast_week_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": kitchen_id, "ws": week_start}) - week_snap = week_snap_result.fetchone() - snapshot_revenue = float(week_snap.total_forecast_revenue) if week_snap and week_snap.total_forecast_revenue else None - - # Get overrides - override_result = await db.execute(text(""" - SELECT id, override_date, period, override_covers, original_forecast, original_otb - FROM cover_overrides - WHERE kitchen_id = :kid - AND override_date >= :ws AND override_date <= :we - ORDER BY override_date, period - """), {"kid": kitchen_id, "ws": week_start, "we": week_end}) - override_rows = override_result.fetchall() - - overrides = [] - override_lookup = {} # (date_str, period) -> row - for row in override_rows: - date_str = row.override_date.isoformat() if hasattr(row.override_date, 'isoformat') else str(row.override_date) - overrides.append(OverrideData( - id=row.id, - override_date=date_str, - period=row.period, - override_covers=row.override_covers, - original_forecast=row.original_forecast, - original_otb=row.original_otb, - )) - override_lookup[(date_str, row.period)] = row - - # Get spend rate overrides - spend_override_result = await db.execute(text(""" - SELECT id, period, food_spend, drinks_spend - FROM spend_rate_overrides - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": kitchen_id, "ws": week_start}) - spend_override_rows = spend_override_result.fetchall() - spend_override_lookup = {row.period: row for row in spend_override_rows} - - # Fetch live forecast data + spend rates from API - settings = await get_settings(db, kitchen_id) - api_spend_rates = {} - api_vat_rate = 1.20 - covers_data = [] - revenue_data = [] - - if settings.forecast_api_url and settings.forecast_api_key: - try: - async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client: - try: - sr = await client.get_spend_rates() - api_spend_rates = sr.get("periods", {}) - api_vat_rate = sr.get("vat_rate", 1.20) - except Exception as e: - logger.warning(f"Failed to fetch spend rates: {e}") - - try: - covers_data = await client.get_covers_forecast(week_start, days=7) - except Exception as e: - logger.warning(f"Failed to fetch covers: {e}") - - try: - revenue_data = await client.get_revenue_forecast(week_start, days=7) - except Exception as e: - logger.warning(f"Failed to fetch revenue: {e}") - except Exception as e: - logger.warning(f"Failed to connect to forecast API: {e}") - - # Build spend rates response (per period) - spend_rates = [] - spend_effective = {} # period -> {food, drinks} - for period in ("breakfast", "lunch", "dinner"): - api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0) - api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0) - - # Get snapshot values (from first day of snapshot, same for all days in a period) - snap_food = None - snap_drinks = None - for d in week_dates: - key = (d.isoformat(), period) - if key in snap_lookup: - snap_food = float(snap_lookup[key].food_spend) if snap_lookup[key].food_spend else None - snap_drinks = float(snap_lookup[key].drinks_spend) if snap_lookup[key].drinks_spend else None - break - - # Get override values - ovr = spend_override_lookup.get(period) - ovr_food = float(ovr.food_spend) if ovr and ovr.food_spend else None - ovr_drinks = float(ovr.drinks_spend) if ovr and ovr.drinks_spend else None - - # Resolve effective: override > snapshot > API - eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food) - eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks) - - spend_effective[period] = {"food": eff_food, "drinks": eff_drinks} - spend_rates.append(SpendRateData( - period=period, - food_spend_api=api_food, - drinks_spend_api=api_drinks, - food_spend_snapshot=snap_food, - drinks_spend_snapshot=snap_drinks, - food_spend_override=ovr_food, - drinks_spend_override=ovr_drinks, - food_spend_effective=eff_food, - drinks_spend_effective=eff_drinks, - )) - - # Build recalculated days - covers_by_date = {d.get("date", ""): d for d in covers_data} - revenue_by_date = {d.get("date", ""): d for d in revenue_data} - recalc_days = [] - total_adjusted_revenue = Decimal("0") - - for d in week_dates: - date_str = d.isoformat() - is_past = d < today - day_covers = covers_by_date.get(date_str, {}) - day_revenue = revenue_by_date.get(date_str, {}) - day_name = day_covers.get("day", d.strftime("%a")) - - periods_data = {} - day_rev = Decimal("0") - - for period in ("breakfast", "lunch", "dinner"): - p_covers = day_covers.get(period, {}) - otb = p_covers.get("otb", 0) or 0 - forecast = p_covers.get("forecast", 0) or 0 - pickup = forecast - otb - - # Get snapshot and override for this day/period - snap = snap_lookup.get((date_str, period)) - ovr = override_lookup.get((date_str, period)) - - snap_forecast = snap.forecast_covers if snap else None - override_val = ovr.override_covers if ovr else None - - if is_past: - # Past day: use actual (forecast value from API is actual for past dates) - effective = forecast - actual = forecast - # Calculate variance vs override or snapshot - variance = None - if override_val is not None: - diff = actual - override_val - if diff != 0: - variance = diff - elif snap_forecast is not None: - diff = actual - snap_forecast - if diff != 0: - variance = diff - - # Past revenue from API (dry revenue) - dry = day_revenue.get("dry", {}) - period_rev = Decimal(str(dry.get("forecast", 0) or 0)) if period == "breakfast" else Decimal("0") - # For past days, use actual total revenue from the API (proportioned by period isn't available, - # so we'll use effective_covers * spend_rates as approximation, but actual total from revenue API) - eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0))) - period_rev = Decimal(str(effective)) * eff_food - - periods_data[period] = { - "actual": actual, - "otb": otb, - "pickup": 0, - "effective": effective, - "override": override_val, - "snapshot": snap_forecast, - "variance": variance, - "is_overridden": False, - } - else: - # Today/future: apply override logic - if override_val is not None: - if otb >= override_val: - effective = otb # OTB supersedes upward - adj_pickup = 0 - else: - effective = override_val - adj_pickup = override_val - otb - is_overridden = True - else: - effective = forecast - adj_pickup = pickup - is_overridden = False - - # Calculate revenue for this period (dry/food only - budget tracks food revenue) - eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0))) - period_rev = Decimal(str(effective)) * eff_food - - periods_data[period] = { - "actual": None, - "otb": otb, - "pickup": adj_pickup, - "effective": effective, - "override": override_val, - "snapshot": snap_forecast, - "variance": None, - "is_overridden": is_overridden, - } - - day_rev += period_rev - - # For past days, use actual revenue from API if available - if is_past and day_revenue: - dry = day_revenue.get("dry", {}) - actual_dry_rev = Decimal(str(dry.get("forecast", 0) or 0)) - if actual_dry_rev > 0: - day_rev = actual_dry_rev - - total_adjusted_revenue += day_rev - - recalc_days.append(RecalcDay( - date=date_str, - day_name=day_name, - is_past=is_past, - periods=periods_data, - day_revenue=float(round(day_rev, 2)), - )) - - return WeeklyOverrideResponse( - week_start=week_start.isoformat(), - week_end=week_end.isoformat(), - has_snapshot=has_snapshot, - vat_rate=api_vat_rate, - snapshot_revenue=snapshot_revenue, - adjusted_revenue=float(round(total_adjusted_revenue, 2)), - snapshots=snapshots, - overrides=overrides, - spend_rates=spend_rates, - recalc_days=recalc_days, - ) - - -@router.post("/snapshot") -async def create_snapshot( - req: SnapshotRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Take a snapshot of the current forecast for a week (all periods, all days).""" - week_start, week_end, week_dates = get_week_dates(req.week_offset) - kitchen_id = current_user.kitchen_id - settings = await get_settings(db, kitchen_id) - - if not settings.forecast_api_url or not settings.forecast_api_key: - raise HTTPException(status_code=400, detail="Forecast API not configured") - - async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client: - covers_data = await client.get_covers_forecast(week_start, days=7) - revenue_data = await client.get_revenue_forecast(week_start, days=7) - spend_rates_response = await client.get_spend_rates() - - api_spend = spend_rates_response.get("periods", {}) - vat_rate = spend_rates_response.get("vat_rate", 1.20) - - covers_by_date = {d.get("date", ""): d for d in covers_data} - revenue_by_date = {d.get("date", ""): d for d in revenue_data} - - # Delete existing snapshots for this week (re-snapshot) - await db.execute(text(""" - DELETE FROM forecast_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": kitchen_id, "ws": week_start}) - - # Insert snapshot for each day/period - total_forecast_rev = Decimal("0") - total_otb_rev = Decimal("0") - - for d in week_dates: - date_str = d.isoformat() - day_covers = covers_by_date.get(date_str, {}) - day_revenue = revenue_by_date.get(date_str, {}) - - for period in ("breakfast", "lunch", "dinner"): - p = day_covers.get(period, {}) - otb = p.get("otb", 0) or 0 - forecast = p.get("forecast", 0) or 0 - - # Spend rates (net, ex VAT) - food_net = api_spend.get(period, {}).get("food_spend_net", 0) - drinks_net = api_spend.get(period, {}).get("drinks_spend_net", 0) - - # Calculate dry revenue for this period/day - dry_rev = Decimal(str(forecast)) * Decimal(str(food_net)) - - await db.execute(text(""" - INSERT INTO forecast_snapshots - (kitchen_id, snapshot_date, period, forecast_covers, otb_covers, - food_spend, drinks_spend, forecast_dry_revenue, week_start) - VALUES (:kid, :sd, :period, :fc, :oc, :fs, :ds, :dr, :ws) - """), { - "kid": kitchen_id, "sd": d, "period": period, - "fc": forecast, "oc": otb, - "fs": food_net, "ds": drinks_net, - "dr": float(round(dry_rev, 2)), - "ws": week_start, - }) - - # Accumulate weekly totals from revenue API - dry = day_revenue.get("dry", {}) - total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0)) - total_otb_rev += Decimal(str(dry.get("otb", 0) or 0)) - - # Divide by 3 since we're iterating 3 periods but revenue data is per-day total - # Actually, the revenue API returns per-day totals, not per-period. - # We accumulated 3x per day. Let's recalculate from revenue_data directly. - total_forecast_rev = Decimal("0") - total_otb_rev = Decimal("0") - for d in revenue_data: - dry = d.get("dry", {}) - total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0)) - total_otb_rev += Decimal(str(dry.get("otb", 0) or 0)) - - # Upsert week snapshot - gp_target = float(settings.budget_gp_target) if settings.budget_gp_target else 65.0 - - await db.execute(text(""" - DELETE FROM forecast_week_snapshots - WHERE kitchen_id = :kid AND week_start = :ws - """), {"kid": kitchen_id, "ws": week_start}) - - await db.execute(text(""" - INSERT INTO forecast_week_snapshots - (kitchen_id, week_start, total_forecast_revenue, total_otb_revenue, gp_target) - VALUES (:kid, :ws, :tfr, :tor, :gp) - """), { - "kid": kitchen_id, "ws": week_start, - "tfr": float(round(total_forecast_rev, 2)), - "tor": float(round(total_otb_rev, 2)), - "gp": gp_target, - }) - - await db.commit() - - return { - "success": True, - "message": "Forecast snapshot created", - "week_start": week_start.isoformat(), - "total_forecast_revenue": float(round(total_forecast_rev, 2)), - "total_otb_revenue": float(round(total_otb_rev, 2)), - "days_snapshotted": len(week_dates), - "periods_per_day": 3, - } - - -@router.put("") -async def upsert_cover_override( - req: CoverOverrideRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Set or update a cover override for a specific date+period.""" - kitchen_id = current_user.kitchen_id - - if req.period not in ("lunch", "dinner"): - raise HTTPException(status_code=400, detail="Period must be 'lunch' or 'dinner'") - - if req.override_covers < 0: - raise HTTPException(status_code=400, detail="Override covers cannot be negative") - - override_date = date.fromisoformat(req.override_date) - - # Check existing - existing = await db.execute(text(""" - SELECT id FROM cover_overrides - WHERE kitchen_id = :kid AND override_date = :od AND period = :p - """), {"kid": kitchen_id, "od": override_date, "p": req.period}) - row = existing.fetchone() - - if row: - await db.execute(text(""" - UPDATE cover_overrides - SET override_covers = :oc, updated_by = :uid, updated_at = NOW() - WHERE id = :id - """), {"oc": req.override_covers, "uid": current_user.id, "id": row.id}) - else: - # Get current forecast for snapshot - settings = await get_settings(db, kitchen_id) - original_forecast = None - original_otb = None - - if settings.forecast_api_url and settings.forecast_api_key: - try: - async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client: - covers = await client.get_covers_forecast(override_date, days=1) - if covers: - p = covers[0].get(req.period, {}) - original_forecast = p.get("forecast", 0) or 0 - original_otb = p.get("otb", 0) or 0 - except Exception as e: - logger.warning(f"Failed to get forecast for snapshot: {e}") - - await db.execute(text(""" - INSERT INTO cover_overrides - (kitchen_id, override_date, period, override_covers, - original_forecast, original_otb, created_by, updated_by) - VALUES (:kid, :od, :p, :oc, :of, :oo, :uid, :uid) - """), { - "kid": kitchen_id, "od": override_date, "p": req.period, - "oc": req.override_covers, "of": original_forecast, "oo": original_otb, - "uid": current_user.id, - }) - - await db.commit() - return {"success": True, "date": req.override_date, "period": req.period, "covers": req.override_covers} - - -@router.delete("/{override_id}") -async def delete_cover_override( - override_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Remove a cover override (revert to forecast).""" - result = await db.execute(text(""" - DELETE FROM cover_overrides - WHERE id = :id AND kitchen_id = :kid - RETURNING id - """), {"id": override_id, "kid": current_user.kitchen_id}) - deleted = result.fetchone() - - if not deleted: - raise HTTPException(status_code=404, detail="Override not found") - - await db.commit() - return {"success": True, "deleted_id": override_id} - - -@router.put("/spend-rates") -async def upsert_spend_rate_override( - req: SpendRateOverrideRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Set or update a spend rate override for a week+period.""" - kitchen_id = current_user.kitchen_id - week_start, _, _ = get_week_dates(req.week_offset) - - if req.period not in ("breakfast", "lunch", "dinner"): - raise HTTPException(status_code=400, detail="Period must be 'breakfast', 'lunch', or 'dinner'") - - # Check existing - existing = await db.execute(text(""" - SELECT id FROM spend_rate_overrides - WHERE kitchen_id = :kid AND week_start = :ws AND period = :p - """), {"kid": kitchen_id, "ws": week_start, "p": req.period}) - row = existing.fetchone() - - if row: - await db.execute(text(""" - UPDATE spend_rate_overrides - SET food_spend = :fs, drinks_spend = :ds, updated_by = :uid, updated_at = NOW() - WHERE id = :id - """), {"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, "id": row.id}) - else: - await db.execute(text(""" - INSERT INTO spend_rate_overrides - (kitchen_id, week_start, period, food_spend, drinks_spend, created_by, updated_by) - VALUES (:kid, :ws, :p, :fs, :ds, :uid, :uid) - """), { - "kid": kitchen_id, "ws": week_start, "p": req.period, - "fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, - }) - - await db.commit() - return {"success": True, "week_start": week_start.isoformat(), "period": req.period} - - -@router.delete("/spend-rates/{override_id}") -async def delete_spend_rate_override( - override_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Remove a spend rate override (revert to snapshot/API value).""" - result = await db.execute(text(""" - DELETE FROM spend_rate_overrides - WHERE id = :id AND kitchen_id = :kid - RETURNING id - """), {"id": override_id, "kid": current_user.kitchen_id}) - deleted = result.fetchone() - - if not deleted: - raise HTTPException(status_code=404, detail="Spend rate override not found") - - await db.commit() - return {"success": True, "deleted_id": override_id} diff --git a/backend/api/credit_notes.py b/backend/api/credit_notes.py deleted file mode 100644 index 0cadc2a..0000000 --- a/backend/api/credit_notes.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -API endpoints for credit note management. - -Handles: -- Credit note upload -- Credit note download -- Credit note CRUD operations -""" -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from fastapi.responses import Response -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ -from sqlalchemy.orm import selectinload -from datetime import date as date_type -from typing import Optional -from pydantic import BaseModel -from decimal import Decimal - -from auth import get_current_user, require_cap -from database import get_db -from models.user import User -from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity -from models.invoice import Invoice -from models.supplier import Supplier -from services.dispute_archival_service import DisputeArchivalService - -router = APIRouter() - - -# Pydantic Schemas - -class CreditNoteResponse(BaseModel): - id: int - invoice_id: int - supplier_id: int - supplier_name: str - credit_note_number: str - credit_date: str - credit_amount: float - reason: Optional[str] - notes: Optional[str] - file_storage_location: str - created_at: str - created_by: str - - class Config: - from_attributes = True - - -# Endpoints - -@router.post("/upload") -async def upload_credit_note( - file: UploadFile = File(...), - invoice_id: int = Form(...), - credit_note_number: str = Form(...), - credit_date: str = Form(...), - credit_amount: float = Form(...), - dispute_id: Optional[int] = Form(None), - reason: Optional[str] = Form(None), - notes: Optional[str] = Form(None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Upload credit note PDF""" - - # Verify invoice exists and belongs to kitchen - result = await db.execute( - select(Invoice).where( - and_( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Parse credit date - try: - credit_date_obj = date_type.fromisoformat(credit_date) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - - # Read file content - file_content = await file.read() - - # Validate file type (should be PDF) - if file.content_type and "pdf" not in file.content_type.lower(): - raise HTTPException(status_code=400, detail="Only PDF files are supported for credit notes") - - # Save file - archival_service = DisputeArchivalService(db, current_user.kitchen_id) - success, file_path = await archival_service.save_credit_note( - invoice, - file_content, - file.filename or "credit_note.pdf" - ) - - if not success: - raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}") - - # Create credit note record - credit_note = CreditNote( - kitchen_id=current_user.kitchen_id, - invoice_id=invoice_id, - supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle) - credit_note_number=credit_note_number, - credit_date=credit_date_obj, - credit_amount=Decimal(str(credit_amount)), - reason=reason, - notes=notes, - file_path=file_path, - file_storage_location="local", - created_by=current_user.id - ) - - db.add(credit_note) - await db.flush() # Get credit_note.id - - # If linked to dispute, update dispute - if dispute_id: - result = await db.execute( - select(InvoiceDispute).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - - if dispute: - # Link credit note to dispute - dispute.credit_note_id = credit_note.id - dispute.resolved_amount = credit_note.credit_amount - - # Update dispute status if still open/in_progress - if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]: - dispute.status = DisputeStatus.AWAITING_CREDIT - - # Log activity - activity = DisputeActivity( - dispute_id=dispute_id, - activity_type="credit_note_added", - description=f"Credit note {credit_note_number} (£{credit_amount:.2f}) linked to dispute", - created_by=current_user.id - ) - db.add(activity) - - await db.commit() - await db.refresh(credit_note) - - # Archive to Nextcloud if enabled - success, result = await archival_service.archive_credit_note(credit_note) - if success: - await db.commit() # Update archived status - - return { - "id": credit_note.id, - "credit_note_number": credit_note_number, - "credit_amount": credit_amount, - "archived": success - } - - -@router.get("/{credit_note_id}") -async def get_credit_note( - credit_note_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> CreditNoteResponse: - """Get credit note details""" - - result = await db.execute( - select(CreditNote).options( - selectinload(CreditNote.supplier), - selectinload(CreditNote.created_by_user) - ).where( - and_( - CreditNote.id == credit_note_id, - CreditNote.kitchen_id == current_user.kitchen_id - ) - ) - ) - credit_note = result.scalar_one_or_none() - - if not credit_note: - raise HTTPException(status_code=404, detail="Credit note not found") - - return CreditNoteResponse( - id=credit_note.id, - invoice_id=credit_note.invoice_id, - supplier_id=credit_note.supplier_id, - supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown", - credit_note_number=credit_note.credit_note_number, - credit_date=credit_note.credit_date.isoformat(), - credit_amount=float(credit_note.credit_amount), - reason=credit_note.reason, - notes=credit_note.notes, - file_storage_location=credit_note.file_storage_location, - created_at=credit_note.created_at.isoformat(), - created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown" - ) - - -@router.get("/{credit_note_id}/download") -async def download_credit_note( - credit_note_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Download credit note PDF""" - - result = await db.execute( - select(CreditNote).where( - and_( - CreditNote.id == credit_note_id, - CreditNote.kitchen_id == current_user.kitchen_id - ) - ) - ) - credit_note = result.scalar_one_or_none() - - if not credit_note: - raise HTTPException(status_code=404, detail="Credit note not found") - - # Get file content - archival_service = DisputeArchivalService(db, current_user.kitchen_id) - success, content = await archival_service.get_credit_note_content(credit_note) - - if not success: - raise HTTPException(status_code=404, detail="File not found") - - return Response( - content=content, - media_type="application/pdf", - headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'} - ) - - -@router.patch("/{credit_note_id}") -async def update_credit_note( - credit_note_id: int, - credit_note_number: Optional[str] = None, - credit_date: Optional[str] = None, - credit_amount: Optional[float] = None, - reason: Optional[str] = None, - notes: Optional[str] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update credit note details""" - - result = await db.execute( - select(CreditNote).where( - and_( - CreditNote.id == credit_note_id, - CreditNote.kitchen_id == current_user.kitchen_id - ) - ) - ) - credit_note = result.scalar_one_or_none() - - if not credit_note: - raise HTTPException(status_code=404, detail="Credit note not found") - - if credit_note_number: - credit_note.credit_note_number = credit_note_number - - if credit_date: - try: - credit_note.credit_date = date_type.fromisoformat(credit_date) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - - if credit_amount is not None: - credit_note.credit_amount = Decimal(str(credit_amount)) - - if reason is not None: - credit_note.reason = reason - - if notes is not None: - credit_note.notes = notes - - await db.commit() - - return {"status": "updated"} - - -@router.delete("/{credit_note_id}") -async def delete_credit_note( - credit_note_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete credit note""" - - result = await db.execute( - select(CreditNote).where( - and_( - CreditNote.id == credit_note_id, - CreditNote.kitchen_id == current_user.kitchen_id - ) - ) - ) - credit_note = result.scalar_one_or_none() - - if not credit_note: - raise HTTPException(status_code=404, detail="Credit note not found") - - # Check if linked to dispute - result = await db.execute( - select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id) - ) - dispute = result.scalar_one_or_none() - - if dispute: - # Unlink from dispute - dispute.credit_note_id = None - dispute.resolved_amount = None - - await db.delete(credit_note) - await db.commit() - - return {"status": "deleted"} diff --git a/backend/api/disputes.py b/backend/api/disputes.py deleted file mode 100644 index 012f0a1..0000000 --- a/backend/api/disputes.py +++ /dev/null @@ -1,1125 +0,0 @@ -""" -API endpoints for invoice dispute tracking. - -Handles: -- Dispute CRUD operations -- Dispute attachments upload/download -- Dispute activity logging -- Dashboard statistics -""" -import secrets -import hashlib -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, func -from sqlalchemy.orm import selectinload -from datetime import datetime, date, timedelta -from typing import Optional, List -from pydantic import BaseModel -from decimal import Decimal - -from auth import get_current_user, require_cap -from database import get_db -from models.user import User -from models.dispute import ( - InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity, - DisputeType, DisputeStatus, DisputePriority -) -from models.invoice import Invoice -from models.supplier import Supplier -from services.dispute_archival_service import DisputeArchivalService - - -def generate_public_hash() -> str: - """Generate a secure random hash for public attachment links""" - return secrets.token_urlsafe(32) # 43 character URL-safe string - -router = APIRouter() - - -# Pydantic Schemas - -class DisputeLineItemInput(BaseModel): - invoice_line_item_id: Optional[int] = None - product_name: str - product_code: Optional[str] = None - quantity_ordered: Optional[float] = None - quantity_received: Optional[float] = None - unit_price_quoted: Optional[float] = None - unit_price_charged: Optional[float] = None - total_charged: float - total_expected: Optional[float] = None - notes: Optional[str] = None - - -class CreateDisputeInput(BaseModel): - invoice_id: int - dispute_type: DisputeType - priority: DisputePriority = DisputePriority.MEDIUM - title: str - description: Optional[str] = "" - disputed_amount: float - expected_amount: Optional[float] = None - line_items: List[DisputeLineItemInput] = [] - tags: Optional[List[str]] = None - - -class UpdateDisputeInput(BaseModel): - status: Optional[DisputeStatus] = None - priority: Optional[DisputePriority] = None - title: Optional[str] = None - description: Optional[str] = None - resolution_notes: Optional[str] = None - supplier_response: Optional[str] = None - supplier_contact_name: Optional[str] = None - resolved_amount: Optional[float] = None - - -class DisputeLineItemResponse(BaseModel): - id: int - product_name: str - product_code: Optional[str] - quantity_ordered: Optional[float] - quantity_received: Optional[float] - quantity_difference: Optional[float] - unit_price_quoted: Optional[float] - unit_price_charged: Optional[float] - price_difference: Optional[float] - total_charged: float - total_expected: Optional[float] - notes: Optional[str] - - class Config: - from_attributes = True - - -class DisputeAttachmentResponse(BaseModel): - id: int - file_name: str - file_type: str - file_size_bytes: int - attachment_type: str - description: Optional[str] - uploaded_at: str - uploaded_by_username: str - public_hash: Optional[str] = None - public_url: Optional[str] = None - - class Config: - from_attributes = True - - -class DisputeActivityResponse(BaseModel): - id: int - activity_type: str - description: str - old_value: Optional[str] = None - new_value: Optional[str] = None - created_at: str - created_by_username: str - - class Config: - from_attributes = True - - -class DisputeResponse(BaseModel): - id: int - invoice_id: int - invoice_number: Optional[str] - supplier_name: str - dispute_type: str - status: str - priority: str - title: str - description: str - disputed_amount: float - expected_amount: Optional[float] - difference_amount: float - supplier_contacted_at: Optional[str] - supplier_response: Optional[str] - supplier_contact_name: Optional[str] - resolved_amount: Optional[float] - opened_at: str - opened_by: str - updated_at: str - resolved_at: Optional[str] - closed_at: Optional[str] - tags: Optional[List[str]] - line_items: List[DisputeLineItemResponse] - attachments: List[DisputeAttachmentResponse] - activity_log: List[DisputeActivityResponse] - - class Config: - from_attributes = True - - -# Endpoints - -@router.get("") -async def get_disputes( - status: Optional[str] = None, - priority: Optional[str] = None, - invoice_id: Optional[int] = None, - supplier_id: Optional[int] = None, - opened_date: Optional[date] = None, - limit: int = 50, - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get disputes with filters""" - - # Build base query - query = select(InvoiceDispute).options( - selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), - selectinload(InvoiceDispute.opened_by_user), - selectinload(InvoiceDispute.resolved_by_user), - selectinload(InvoiceDispute.closed_by_user), - selectinload(InvoiceDispute.line_items), - selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), - selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) - ).where(InvoiceDispute.kitchen_id == current_user.kitchen_id) - - # Join with Invoice for supplier filtering - if supplier_id: - query = query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id) - - if status: - try: - status_enum = DisputeStatus(status) - query = query.where(InvoiceDispute.status == status_enum) - except ValueError: - pass - - if priority: - try: - priority_enum = DisputePriority(priority) - query = query.where(InvoiceDispute.priority == priority_enum) - except ValueError: - pass - - if invoice_id: - query = query.where(InvoiceDispute.invoice_id == invoice_id) - - if opened_date: - day_start = datetime.combine(opened_date, datetime.min.time()) - day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time()) - query = query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end) - - # Count total (before pagination) - count_query = select(func.count()).select_from(InvoiceDispute).where(InvoiceDispute.kitchen_id == current_user.kitchen_id) - if supplier_id: - count_query = count_query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id) - if status: - try: - status_enum = DisputeStatus(status) - count_query = count_query.where(InvoiceDispute.status == status_enum) - except ValueError: - pass - if priority: - try: - priority_enum = DisputePriority(priority) - count_query = count_query.where(InvoiceDispute.priority == priority_enum) - except ValueError: - pass - if invoice_id: - count_query = count_query.where(InvoiceDispute.invoice_id == invoice_id) - if opened_date: - day_start = datetime.combine(opened_date, datetime.min.time()) - day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time()) - count_query = count_query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end) - - total_result = await db.execute(count_query) - total = total_result.scalar() - - # Apply pagination - query = query.order_by(InvoiceDispute.opened_at.desc()) - query = query.limit(limit).offset(offset) - - result = await db.execute(query) - disputes = result.scalars().all() - - return { - "disputes": [_format_dispute(dispute) for dispute in disputes], - "total": total - } - - -@router.get("/{dispute_id}") -async def get_dispute( - dispute_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> DisputeResponse: - """Get single dispute by ID""" - - result = await db.execute( - select(InvoiceDispute).options( - selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), - selectinload(InvoiceDispute.opened_by_user), - selectinload(InvoiceDispute.resolved_by_user), - selectinload(InvoiceDispute.closed_by_user), - selectinload(InvoiceDispute.line_items), - selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), - selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) - ).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - return _format_dispute(dispute) - - -@router.post("") -async def create_dispute( - dispute_input: CreateDisputeInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> DisputeResponse: - """Create new invoice dispute""" - - # Verify invoice belongs to kitchen - result = await db.execute( - select(Invoice).where( - and_( - Invoice.id == dispute_input.invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Calculate difference - expected = Decimal(str(dispute_input.expected_amount)) if dispute_input.expected_amount else Decimal(0) - disputed = Decimal(str(dispute_input.disputed_amount)) - difference = disputed - expected - - # Create dispute - dispute = InvoiceDispute( - kitchen_id=current_user.kitchen_id, - invoice_id=dispute_input.invoice_id, - dispute_type=dispute_input.dispute_type, - priority=dispute_input.priority, - status=DisputeStatus.NEW, - title=dispute_input.title, - description=dispute_input.description, - disputed_amount=disputed, - expected_amount=expected if dispute_input.expected_amount else None, - difference_amount=difference, - opened_by=current_user.id, - tags=dispute_input.tags - ) - - db.add(dispute) - await db.flush() # Get dispute.id - - # Add line items - for item_input in dispute_input.line_items: - qty_diff = None - if item_input.quantity_ordered is not None and item_input.quantity_received is not None: - qty_diff = Decimal(str(item_input.quantity_ordered)) - Decimal(str(item_input.quantity_received)) - - price_diff = None - if item_input.unit_price_quoted is not None and item_input.unit_price_charged is not None: - price_diff = Decimal(str(item_input.unit_price_charged)) - Decimal(str(item_input.unit_price_quoted)) - - line_item = DisputeLineItem( - dispute_id=dispute.id, - invoice_line_item_id=item_input.invoice_line_item_id, - product_name=item_input.product_name, - product_code=item_input.product_code, - quantity_ordered=Decimal(str(item_input.quantity_ordered)) if item_input.quantity_ordered else None, - quantity_received=Decimal(str(item_input.quantity_received)) if item_input.quantity_received else None, - quantity_difference=qty_diff, - unit_price_quoted=Decimal(str(item_input.unit_price_quoted)) if item_input.unit_price_quoted else None, - unit_price_charged=Decimal(str(item_input.unit_price_charged)) if item_input.unit_price_charged else None, - price_difference=price_diff, - total_charged=Decimal(str(item_input.total_charged)), - total_expected=Decimal(str(item_input.total_expected)) if item_input.total_expected else None, - notes=item_input.notes - ) - db.add(line_item) - - # Log activity - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="created", - description=f"Dispute created: {dispute.title}", - created_by=current_user.id - ) - db.add(activity) - - await db.commit() - await db.refresh(dispute) - - # Re-fetch with all relationships - result = await db.execute( - select(InvoiceDispute).options( - selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), - selectinload(InvoiceDispute.opened_by_user), - selectinload(InvoiceDispute.line_items), - selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), - selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) - ).where(InvoiceDispute.id == dispute.id) - ) - dispute = result.scalar_one() - - return _format_dispute(dispute) - - -@router.patch("/{dispute_id}") -async def update_dispute( - dispute_id: int, - update_input: UpdateDisputeInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update dispute status or details""" - - result = await db.execute( - select(InvoiceDispute).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - # Track changes for activity log - changes = [] - - if update_input.status and update_input.status != dispute.status: - old_status = dispute.status.value.upper() - new_status = update_input.status.value.upper() - dispute.status = update_input.status - - # Log status change with specific activity_type - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="status_change", - description=f"Status changed from {old_status} to {new_status}", - created_by=current_user.id - ) - db.add(activity) - changes.append(f"Status changed from {old_status} to {new_status}") - - # Auto-set resolved_at when status changes to resolved - if update_input.status == DisputeStatus.RESOLVED and not dispute.resolved_at: - dispute.resolved_at = datetime.utcnow() - dispute.resolved_by = current_user.id - - # Auto-set closed_at when status changes to closed - if update_input.status == DisputeStatus.CLOSED and not dispute.closed_at: - dispute.closed_at = datetime.utcnow() - dispute.closed_by = current_user.id - - if update_input.priority and update_input.priority != dispute.priority: - old_priority = dispute.priority.value.upper() - new_priority = update_input.priority.value.upper() - dispute.priority = update_input.priority - - # Log priority change with specific activity_type - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="priority_change", - description=f"Priority changed from {old_priority} to {new_priority}", - created_by=current_user.id - ) - db.add(activity) - changes.append(f"Priority changed from {old_priority} to {new_priority}") - - if update_input.title and update_input.title != dispute.title: - old_title = dispute.title - dispute.title = update_input.title - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="updated", - description=f"Title updated from '{old_title}' to '{update_input.title}'", - created_by=current_user.id - ) - db.add(activity) - changes.append("Title updated") - - if update_input.description is not None and update_input.description != dispute.description: - dispute.description = update_input.description - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="updated", - description="Description updated", - created_by=current_user.id - ) - db.add(activity) - changes.append("Description updated") - - if update_input.resolution_notes: - dispute.resolution_notes = update_input.resolution_notes - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="note", - description=update_input.resolution_notes, - created_by=current_user.id - ) - db.add(activity) - changes.append("Resolution notes updated") - - if update_input.supplier_response: - dispute.supplier_response = update_input.supplier_response - if not dispute.supplier_contacted_at: - dispute.supplier_contacted_at = datetime.utcnow() - - # Log note with specific activity_type - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="note", - description=update_input.supplier_response, - created_by=current_user.id - ) - db.add(activity) - changes.append(update_input.supplier_response) - - if update_input.supplier_contact_name: - dispute.supplier_contact_name = update_input.supplier_contact_name - - if update_input.resolved_amount is not None: - dispute.resolved_amount = Decimal(str(update_input.resolved_amount)) - activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="updated", - description=f"Resolved amount set to £{update_input.resolved_amount:.2f}", - created_by=current_user.id - ) - db.add(activity) - changes.append(f"Resolved amount set to £{update_input.resolved_amount:.2f}") - - dispute.updated_at = datetime.utcnow() - - await db.commit() - - return {"status": "updated", "changes": changes} - - -@router.delete("/{dispute_id}") -async def delete_dispute( - dispute_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete a dispute (admin only, use carefully)""" - - result = await db.execute( - select(InvoiceDispute).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - # Cascade delete handles attachments, line items, activity - await db.delete(dispute) - await db.commit() - - return {"status": "deleted"} - - -@router.post("/{dispute_id}/attachments") -async def upload_dispute_attachment( - dispute_id: int, - file: UploadFile = File(...), - attachment_type: str = "other", - description: Optional[str] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Upload supporting document (photo, email, delivery note)""" - - # Verify dispute exists - result = await db.execute( - select(InvoiceDispute).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - # Read file content - file_content = await file.read() - file_size = len(file_content) - - # Save file - archival_service = DisputeArchivalService(db, current_user.kitchen_id) - success, file_path = await archival_service.save_dispute_attachment( - dispute, - file_content, - file.filename or "attachment", - file.content_type or "application/octet-stream" - ) - - if not success: - raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}") - - # Generate public hash for shareable link - public_hash = generate_public_hash() - - # Create attachment record - attachment = DisputeAttachment( - dispute_id=dispute_id, - kitchen_id=current_user.kitchen_id, - file_name=file.filename or "attachment", - file_path=file_path, - file_type=file.content_type or "application/octet-stream", - file_size_bytes=file_size, - attachment_type=attachment_type, - description=description, - uploaded_by=current_user.id, - public_hash=public_hash - ) - - db.add(attachment) - - # Log activity - activity = DisputeActivity( - dispute_id=dispute_id, - activity_type="attachment_added", - description=f"Attachment added: {file.filename} ({attachment_type})", - created_by=current_user.id - ) - db.add(activity) - - await db.commit() - await db.refresh(attachment) - - # Archive to Nextcloud if enabled - success, result = await archival_service.archive_dispute_attachment(attachment) - if success: - await db.commit() # Update archived status - - return { - "id": attachment.id, - "file_name": file.filename, - "file_size": file_size, - "archived": success, - "public_hash": public_hash, - "public_url": f"/api/public/attachments/{public_hash}" - } - - -@router.get("/{dispute_id}/attachments/{attachment_id}") -async def download_dispute_attachment( - dispute_id: int, - attachment_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Download dispute attachment""" - from fastapi.responses import Response - - result = await db.execute( - select(DisputeAttachment).where( - and_( - DisputeAttachment.id == attachment_id, - DisputeAttachment.dispute_id == dispute_id, - DisputeAttachment.kitchen_id == current_user.kitchen_id - ) - ) - ) - attachment = result.scalar_one_or_none() - if not attachment: - raise HTTPException(status_code=404, detail="Attachment not found") - - # Get file content - archival_service = DisputeArchivalService(db, current_user.kitchen_id) - success, content = await archival_service.get_attachment_content(attachment) - - if not success: - raise HTTPException(status_code=404, detail="File not found") - - return Response( - content=content, - media_type=attachment.file_type, - headers={"Content-Disposition": f'inline; filename="{attachment.file_name}"'} - ) - - -@router.get("/stats/summary") -async def get_dispute_stats( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get dispute statistics for dashboard widget""" - - # Count by status - result = await db.execute( - select( - InvoiceDispute.status, - func.count(InvoiceDispute.id).label("count"), - func.sum(InvoiceDispute.difference_amount).label("total_amount") - ).where( - InvoiceDispute.kitchen_id == current_user.kitchen_id - ).group_by(InvoiceDispute.status) - ) - status_counts = { - row.status.value: { - "count": row.count, - "amount": float(row.total_amount or 0) - } - for row in result - } - - # Recent disputes - result = await db.execute( - select(InvoiceDispute).options( - selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier) - ).where( - InvoiceDispute.kitchen_id == current_user.kitchen_id - ).order_by(InvoiceDispute.opened_at.desc()).limit(5) - ) - recent_disputes = result.scalars().all() - - # Count open disputes (all non-resolved statuses) - open_count = sum( - status_counts.get(status, {}).get("count", 0) - for status in ["NEW", "CONTACTED", "AWAITING_CREDIT", "AWAITING_REPLACEMENT"] - ) - - # Total disputed amount - total_disputed_amount = sum(s.get("amount", 0) for s in status_counts.values()) - - return { - "status_counts": status_counts, - "total_disputes": sum(s.get("count", 0) for s in status_counts.values()), - "open_disputes": open_count, - "total_disputed_amount": total_disputed_amount, - "recent_disputes": [ - { - "id": d.id, - "invoice_id": d.invoice_id, - "title": d.title, - "status": d.status.value, - "disputed_amount": float(d.difference_amount), - "opened_at": d.opened_at.isoformat(), - "invoice_number": d.invoice.invoice_number if d.invoice else None, - "supplier_name": d.invoice.supplier.name if d.invoice and d.invoice.supplier else "Unknown" - } - for d in recent_disputes - ] - } - - -@router.get("/stats/daily") -async def get_daily_dispute_stats( - from_date: Optional[date] = None, - to_date: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily dispute statistics for a date range (for purchases chart integration). - Returns totals split by resolved/unresolved status for color-coding.""" - from sqlalchemy import cast, Date, case - - resolved_statuses = [DisputeStatus.RESOLVED, DisputeStatus.CLOSED] - - query = select( - cast(InvoiceDispute.opened_at, Date).label("date"), - func.count(InvoiceDispute.id).label("count"), - func.sum(InvoiceDispute.disputed_amount).label("total_disputed"), - # Unresolved breakdown - func.sum(case( - (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.disputed_amount), - else_=0 - )).label("unresolved_total"), - func.count(case( - (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.id), - )).label("unresolved_count"), - # Resolved breakdown - func.sum(case( - (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.disputed_amount), - else_=0 - )).label("resolved_total"), - func.count(case( - (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.id), - )).label("resolved_count"), - ).where( - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - - if from_date: - query = query.where(cast(InvoiceDispute.opened_at, Date) >= from_date) - if to_date: - query = query.where(cast(InvoiceDispute.opened_at, Date) <= to_date) - - query = query.group_by(cast(InvoiceDispute.opened_at, Date)) - query = query.order_by(cast(InvoiceDispute.opened_at, Date)) - - result = await db.execute(query) - rows = result.all() - - return { - "daily_stats": { - row.date.isoformat(): { - "count": row.count, - "total_disputed": float(row.total_disputed or 0), - "unresolved_count": row.unresolved_count, - "unresolved_total": float(row.unresolved_total or 0), - "resolved_count": row.resolved_count, - "resolved_total": float(row.resolved_total or 0), - } - for row in rows - } - } - - -def _format_dispute(dispute: InvoiceDispute) -> DisputeResponse: - """Format dispute for API response""" - - supplier_name = "Unknown" - if dispute.invoice and dispute.invoice.supplier: - supplier_name = dispute.invoice.supplier.name - elif dispute.invoice and dispute.invoice.vendor_name: - supplier_name = dispute.invoice.vendor_name - - return DisputeResponse( - id=dispute.id, - invoice_id=dispute.invoice_id, - invoice_number=dispute.invoice.invoice_number if dispute.invoice else None, - supplier_name=supplier_name, - dispute_type=dispute.dispute_type.value, - status=dispute.status.value, - priority=dispute.priority.value, - title=dispute.title, - description=dispute.description, - disputed_amount=float(dispute.disputed_amount), - expected_amount=float(dispute.expected_amount) if dispute.expected_amount else None, - difference_amount=float(dispute.difference_amount), - supplier_contacted_at=dispute.supplier_contacted_at.isoformat() if dispute.supplier_contacted_at else None, - supplier_response=dispute.supplier_response, - supplier_contact_name=dispute.supplier_contact_name, - resolved_amount=float(dispute.resolved_amount) if dispute.resolved_amount else None, - opened_at=dispute.opened_at.isoformat(), - opened_by=dispute.opened_by_user.name if dispute.opened_by_user else "Unknown", - updated_at=dispute.updated_at.isoformat(), - resolved_at=dispute.resolved_at.isoformat() if dispute.resolved_at else None, - closed_at=dispute.closed_at.isoformat() if dispute.closed_at else None, - tags=dispute.tags, - line_items=[ - DisputeLineItemResponse( - id=item.id, - product_name=item.product_name, - product_code=item.product_code, - quantity_ordered=float(item.quantity_ordered) if item.quantity_ordered else None, - quantity_received=float(item.quantity_received) if item.quantity_received else None, - quantity_difference=float(item.quantity_difference) if item.quantity_difference else None, - unit_price_quoted=float(item.unit_price_quoted) if item.unit_price_quoted else None, - unit_price_charged=float(item.unit_price_charged) if item.unit_price_charged else None, - price_difference=float(item.price_difference) if item.price_difference else None, - total_charged=float(item.total_charged), - total_expected=float(item.total_expected) if item.total_expected else None, - notes=item.notes - ) - for item in dispute.line_items - ], - attachments=[ - DisputeAttachmentResponse( - id=att.id, - file_name=att.file_name, - file_type=att.file_type, - file_size_bytes=att.file_size_bytes, - attachment_type=att.attachment_type, - description=att.description, - uploaded_at=att.uploaded_at.isoformat(), - uploaded_by_username=att.uploaded_by_user.name if att.uploaded_by_user else "Unknown", - public_hash=att.public_hash, - public_url=f"/api/public/attachments/{att.public_hash}" if att.public_hash else None - ) - for att in dispute.attachments - ], - activity_log=[ - DisputeActivityResponse( - id=act.id, - activity_type=act.activity_type, - description=act.description, - old_value=act.old_value, - new_value=act.new_value, - created_at=act.created_at.isoformat(), - created_by_username=act.created_by_user.name if act.created_by_user else "Unknown" - ) - for act in sorted(dispute.activity_log, key=lambda x: x.created_at) - ] - ) - - -# ===== Credit Note Linking Endpoints ===== - -class OpenDisputeResponse(BaseModel): - """Simplified dispute info for linking modal""" - id: int - title: str - dispute_type: str - status: str - disputed_amount: float - opened_at: str - invoice_number: Optional[str] = None - - -class LinkCreditNoteInput(BaseModel): - """Input for linking a credit note to a dispute""" - credit_note_invoice_id: int # The invoice ID with document_type='credit_note' - resolved_amount: Optional[float] = None # Optional: override the credit note amount - resolution_notes: Optional[str] = None # Optional: additional notes - - -@router.get("/supplier/{supplier_id}/open", response_model=List[OpenDisputeResponse]) -async def get_open_disputes_for_supplier( - supplier_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get all open/unresolved disputes for a specific supplier""" - # Open statuses (not resolved or closed) - open_statuses = [ - DisputeStatus.NEW, - DisputeStatus.OPEN, - DisputeStatus.CONTACTED, - DisputeStatus.IN_PROGRESS, - DisputeStatus.AWAITING_CREDIT, - DisputeStatus.AWAITING_REPLACEMENT, - DisputeStatus.ESCALATED - ] - - result = await db.execute( - select(InvoiceDispute, Invoice.invoice_number) - .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) - .where( - and_( - InvoiceDispute.kitchen_id == current_user.kitchen_id, - Invoice.supplier_id == supplier_id, - InvoiceDispute.status.in_(open_statuses) - ) - ) - .order_by(InvoiceDispute.opened_at.desc()) - ) - rows = result.all() - - return [ - OpenDisputeResponse( - id=dispute.id, - title=dispute.title, - dispute_type=dispute.dispute_type.value, - status=dispute.status.value, - disputed_amount=float(dispute.disputed_amount), - opened_at=dispute.opened_at.isoformat(), - invoice_number=invoice_number - ) - for dispute, invoice_number in rows - ] - - -@router.post("/{dispute_id}/link-credit-note") -async def link_credit_note_to_dispute( - dispute_id: int, - link_input: LinkCreditNoteInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Link a credit note invoice to a dispute and mark it as resolved""" - # Get the dispute - result = await db.execute( - select(InvoiceDispute).where( - and_( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id - ) - ) - ) - dispute = result.scalar_one_or_none() - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - # Get the credit note invoice - result = await db.execute( - select(Invoice).where( - and_( - Invoice.id == link_input.credit_note_invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - ) - credit_note = result.scalar_one_or_none() - if not credit_note: - raise HTTPException(status_code=404, detail="Credit note not found") - - # Verify it's a credit note - if credit_note.document_type != 'credit_note': - raise HTTPException(status_code=400, detail="Invoice is not a credit note") - - # Determine resolved amount - resolved_amount = link_input.resolved_amount - if resolved_amount is None and credit_note.total: - # Use the credit note total (as positive value) - resolved_amount = abs(float(credit_note.total)) - - # Update the dispute - old_status = dispute.status.value - dispute.status = DisputeStatus.RESOLVED - dispute.resolved_amount = Decimal(str(resolved_amount)) if resolved_amount else None - dispute.resolved_by = current_user.id - dispute.resolved_at = datetime.utcnow() - - if link_input.resolution_notes: - dispute.resolution_notes = link_input.resolution_notes - - # Build credit note reference - credit_note_ref = credit_note.invoice_number or f"ID#{credit_note.id}" - credit_note_date = credit_note.invoice_date.strftime('%d %b %Y') if credit_note.invoice_date else 'unknown date' - - # Add activity for status change - status_activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="status_change", - description=f"Status changed from {old_status} to RESOLVED", - old_value=old_status, - new_value="RESOLVED", - created_by=current_user.id - ) - db.add(status_activity) - - # Add activity for credit note link - link_activity = DisputeActivity( - dispute_id=dispute.id, - activity_type="credit_note_linked", - description=f"Dispute resolved with credit note #{credit_note_ref} dated {credit_note_date}", - new_value=str(credit_note.id), # Store invoice ID for linking - created_by=current_user.id - ) - db.add(link_activity) - - # Update the credit note to track the linked dispute - credit_note.linked_dispute_id = dispute.id - - await db.commit() - - return { - "success": True, - "message": f"Dispute linked to credit note #{credit_note_ref}", - "dispute_id": dispute.id, - "credit_note_id": credit_note.id, - "credit_note_number": credit_note_ref, - "resolved_amount": resolved_amount - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.post("/{dispute_id}/draft-email") -async def draft_email( - dispute_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Draft a supplier dispute email using AI.""" - from services.llm_service import draft_dispute_email - from models.settings import KitchenSettings - - # Load dispute with line items - result = await db.execute( - select(InvoiceDispute) - .options(selectinload(InvoiceDispute.line_items)) - .where( - InvoiceDispute.id == dispute_id, - InvoiceDispute.kitchen_id == current_user.kitchen_id, - ) - ) - dispute = result.scalar_one_or_none() - if not dispute: - raise HTTPException(status_code=404, detail="Dispute not found") - - # Load supplier name via invoice - from models.invoice import Invoice - from models.supplier import Supplier - inv_result = await db.execute( - select(Invoice).where(Invoice.id == dispute.invoice_id) - ) - invoice = inv_result.scalar_one_or_none() - supplier_name = "Unknown Supplier" - if invoice and invoice.supplier_id: - sup_result = await db.execute( - select(Supplier.name).where(Supplier.id == invoice.supplier_id) - ) - sup_row = sup_result.scalar_one_or_none() - if sup_row: - supplier_name = sup_row - - # Load kitchen details - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - kitchen_details = { - "name": getattr(settings, "kitchen_display_name", "") or "", - "address": " ".join(filter(None, [ - getattr(settings, "kitchen_address_line1", ""), - getattr(settings, "kitchen_address_line2", ""), - getattr(settings, "kitchen_city", ""), - getattr(settings, "kitchen_postcode", ""), - ])), - "email": getattr(settings, "kitchen_email", "") or "", - "phone": getattr(settings, "kitchen_phone", "") or "", - } - - dispute_data = { - "supplier_name": supplier_name, - "invoice_number": invoice.invoice_number if invoice else None, - "invoice_date": str(invoice.invoice_date) if invoice and invoice.invoice_date else None, - "dispute_type": dispute.dispute_type.value if dispute.dispute_type else "price_discrepancy", - "title": dispute.title, - "description": dispute.description, - "disputed_amount": float(dispute.disputed_amount) if dispute.disputed_amount else 0, - "line_items": [ - { - "product_name": li.product_name, - "product_code": li.product_code, - "quantity_ordered": float(li.quantity_ordered) if li.quantity_ordered else None, - "quantity_received": float(li.quantity_received) if li.quantity_received else None, - "unit_price_quoted": float(li.unit_price_quoted) if li.unit_price_quoted else None, - "unit_price_charged": float(li.unit_price_charged) if li.unit_price_charged else None, - "total_charged": float(li.total_charged) if li.total_charged else 0, - "total_expected": float(li.total_expected) if li.total_expected else None, - } - for li in (dispute.line_items or []) - ], - } - - llm_result = await draft_dispute_email( - db=db, - kitchen_id=current_user.kitchen_id, - dispute_data=dispute_data, - kitchen_details=kitchen_details, - ) - - return { - "llm_status": llm_result["status"], - "email_subject": llm_result.get("email_subject"), - "email_body": llm_result.get("email_body"), - "error": llm_result.get("error"), - } diff --git a/backend/api/event_orders.py b/backend/api/event_orders.py deleted file mode 100644 index fb9920f..0000000 --- a/backend/api/event_orders.py +++ /dev/null @@ -1,694 +0,0 @@ -""" -Event Order API — create event orders, add recipes × quantities, -generate aggregated shopping lists, and optionally create purchase orders. -""" -import logging -from datetime import date -from decimal import Decimal -from typing import Optional -from collections import defaultdict - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, delete -from sqlalchemy.orm import selectinload -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.event_order import EventOrder, EventOrderItem -from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe -from models.ingredient import Ingredient, IngredientSource, IngredientCategory -from models.supplier import Supplier -from auth import get_current_user, require_cap -from api.ingredients import convert_to_standard, UNIT_CONVERSIONS - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# ── Pydantic schemas ───────────────────────────────────────────────────────── - -class EventOrderCreate(BaseModel): - name: str - event_date: Optional[date] = None - notes: Optional[str] = None - -class EventOrderUpdate(BaseModel): - name: Optional[str] = None - event_date: Optional[date] = None - notes: Optional[str] = None - status: Optional[str] = None - -class EventOrderItemAdd(BaseModel): - recipe_id: int - quantity: int - notes: Optional[str] = None - sort_order: int = 0 - -class EventOrderItemUpdate(BaseModel): - quantity: Optional[int] = None - notes: Optional[str] = None - sort_order: Optional[int] = None - -class BulkItemEntry(BaseModel): - recipe_id: int - quantity: int - notes: Optional[str] = None - -class EventOrderBulkAdd(BaseModel): - items: list[BulkItemEntry] - -class EventOrderResponse(BaseModel): - id: int - name: str - event_date: Optional[str] = None - notes: Optional[str] = None - status: str - item_count: int = 0 - estimated_cost: Optional[float] = None - created_at: str = "" - updated_at: str = "" - -class EventOrderItemResponse(BaseModel): - id: int - recipe_id: int - recipe_name: str = "" - recipe_type: str = "" - batch_portions: int = 1 - quantity: int - cost_per_portion: Optional[float] = None - subtotal: Optional[float] = None - notes: Optional[str] = None - sort_order: int = 0 - - -# ── Event Order CRUD ───────────────────────────────────────────────────────── - -@router.get("") -async def list_event_orders( - status: Optional[str] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - query = ( - select(EventOrder) - .options(selectinload(EventOrder.items)) - .where(EventOrder.kitchen_id == user.kitchen_id) - ) - if status: - query = query.where(EventOrder.status == status) - - result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc())) - orders = result.scalars().all() - - responses = [] - for o in orders: - responses.append(EventOrderResponse( - id=o.id, - name=o.name, - event_date=str(o.event_date) if o.event_date else None, - notes=o.notes, - status=o.status, - item_count=len(o.items) if o.items else 0, - created_at=str(o.created_at) if o.created_at else "", - updated_at=str(o.updated_at) if o.updated_at else "", - )) - - return responses - - -@router.post("") -async def create_event_order( - data: EventOrderCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - order = EventOrder( - kitchen_id=user.kitchen_id, - name=data.name, - event_date=data.event_date, - notes=data.notes, - created_by=user.id, - ) - db.add(order) - await db.commit() - await db.refresh(order) - return {"id": order.id, "name": order.name} - - -@router.get("/{order_id}") -async def get_event_order( - order_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(EventOrder) - .options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe)) - .where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) - ) - order = result.scalar_one_or_none() - if not order: - raise HTTPException(404, "Event order not found") - - items = [] - for item in sorted(order.items, key=lambda x: x.sort_order): - recipe = item.recipe - # Get cost per portion from latest snapshot - from models.recipe import RecipeCostSnapshot - snap_result = await db.execute( - select(RecipeCostSnapshot) - .where(RecipeCostSnapshot.recipe_id == item.recipe_id) - .order_by(RecipeCostSnapshot.snapshot_date.desc()) - .limit(1) - ) - snap = snap_result.scalar_one_or_none() - cpp = float(snap.cost_per_portion) if snap else None - subtotal = cpp * item.quantity if cpp else None - - items.append(EventOrderItemResponse( - id=item.id, - recipe_id=item.recipe_id, - recipe_name=recipe.name if recipe else "", - recipe_type=recipe.recipe_type if recipe else "", - batch_portions=recipe.batch_portions if recipe else 1, - quantity=item.quantity, - cost_per_portion=cpp, - subtotal=round(subtotal, 2) if subtotal else None, - notes=item.notes, - sort_order=item.sort_order, - )) - - return { - "id": order.id, - "name": order.name, - "event_date": str(order.event_date) if order.event_date else None, - "notes": order.notes, - "status": order.status, - "items": [i.model_dump() for i in items], - "created_at": str(order.created_at) if order.created_at else "", - "updated_at": str(order.updated_at) if order.updated_at else "", - } - - -@router.patch("/{order_id}") -async def update_event_order( - order_id: int, - data: EventOrderUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) - ) - order = result.scalar_one_or_none() - if not order: - raise HTTPException(404, "Event order not found") - if data.name is not None: - order.name = data.name - if data.event_date is not None: - order.event_date = data.event_date - if data.notes is not None: - order.notes = data.notes - if data.status is not None: - order.status = data.status - await db.commit() - return {"ok": True} - - -@router.delete("/{order_id}") -async def delete_event_order( - order_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) - ) - order = result.scalar_one_or_none() - if not order: - raise HTTPException(404, "Event order not found") - if order.status != "DRAFT": - raise HTTPException(400, "Only DRAFT orders can be deleted") - await db.delete(order) - await db.commit() - return {"ok": True} - - -# ── Event Order Items ──────────────────────────────────────────────────────── - -@router.post("/{order_id}/items") -async def add_item( - order_id: int, - data: EventOrderItemAdd, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - order = await _get_order(order_id, user.kitchen_id, db) - recipe = await db.execute( - select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not recipe.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - item = EventOrderItem( - event_order_id=order_id, - recipe_id=data.recipe_id, - quantity=data.quantity, - notes=data.notes, - sort_order=data.sort_order, - ) - db.add(item) - await db.commit() - await db.refresh(item) - return {"id": item.id} - - -@router.post("/{order_id}/items/bulk") -async def add_items_bulk( - order_id: int, - data: EventOrderBulkAdd, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Add multiple recipes to an event order at once (e.g. from a menu).""" - order = await _get_order(order_id, user.kitchen_id, db) - - # Get existing recipe_ids on this order to detect duplicates - existing_result = await db.execute( - select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id) - ) - existing_ids = {r[0] for r in existing_result.all()} - - # Get max sort_order - max_sort_result = await db.execute( - select(func.coalesce(func.max(EventOrderItem.sort_order), -1)) - .where(EventOrderItem.event_order_id == order_id) - ) - next_sort = (max_sort_result.scalar() or 0) + 1 - - # Validate all recipe_ids belong to this kitchen - recipe_ids = [entry.recipe_id for entry in data.items] - valid_result = await db.execute( - select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id) - ) - valid_ids = {r[0] for r in valid_result.all()} - - added = 0 - skipped = 0 - for entry in data.items: - if entry.recipe_id not in valid_ids: - skipped += 1 - continue - if entry.recipe_id in existing_ids: - skipped += 1 - continue - item = EventOrderItem( - event_order_id=order_id, - recipe_id=entry.recipe_id, - quantity=entry.quantity, - notes=entry.notes, - sort_order=next_sort, - ) - db.add(item) - existing_ids.add(entry.recipe_id) - next_sort += 1 - added += 1 - - await db.commit() - return {"added": added, "skipped": skipped} - - -@router.patch("/items/{item_id}") -async def update_item( - item_id: int, - data: EventOrderItemUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Item not found") - if data.quantity is not None: - item.quantity = data.quantity - if data.notes is not None: - item.notes = data.notes - if data.sort_order is not None: - item.sort_order = data.sort_order - await db.commit() - return {"ok": True} - - -@router.delete("/items/{item_id}") -async def delete_item( - item_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Item not found") - await db.delete(item) - await db.commit() - return {"ok": True} - - -# ── Shopping List ──────────────────────────────────────────────────────────── - -async def _collect_ingredients_for_recipe( - recipe_id: int, - multiplier: float, - db: AsyncSession, - depth: int = 0, -) -> dict[int, float]: - """Recursively collect ingredient quantities for a recipe × multiplier. - Returns {ingredient_id: total_quantity_in_standard_unit}.""" - if depth > 5: - return {} - - result: dict[int, float] = defaultdict(float) - - # Direct ingredients (yield-adjusted: divide by yield to get required purchase qty) - ri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient)) - .where(RecipeIngredient.recipe_id == recipe_id) - ) - _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} - for ri in ri_result.scalars().all(): - yld = float(ri.yield_percent) if ri.yield_percent else 100.0 - raw_qty = float(ri.quantity) * multiplier - # Convert from display unit to standard unit if needed - if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit: - from_base = _bases.get(ri.unit) - to_base = _bases.get(ri.ingredient.standard_unit) - if from_base and to_base: - raw_qty = raw_qty * from_base / to_base - adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty - result[ri.ingredient_id] += adjusted_qty - - # Sub-recipe ingredients - sr_result = await db.execute( - select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id) - ) - for sr in sr_result.scalars().all(): - child_result = await db.execute( - select(Recipe).where(Recipe.id == sr.child_recipe_id) - ) - child_recipe = child_result.scalar_one_or_none() - if not child_recipe: - continue - # Use unified output qty (handles both portioned and bulk) - child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1) - child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion" - # Convert portions_needed to child output unit if different unit was used - needed = float(sr.portions_needed) - needed_unit = sr.portions_needed_unit or child_output_unit - if needed_unit != child_output_unit: - _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} - if needed_unit in _bases and child_output_unit in _bases: - needed = needed * _bases[needed_unit] / _bases[child_output_unit] - child_multiplier = multiplier * (needed / child_output_qty) - child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1) - for ing_id, qty in child_ings.items(): - result[ing_id] += qty - - return dict(result) - - -@router.get("/{order_id}/shopping-list") -async def get_shopping_list( - order_id: int, - group_by_supplier: bool = Query(False), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Aggregated ingredient shopping list across all event order items.""" - order = await _get_order(order_id, user.kitchen_id, db) - - # Get all items - items_result = await db.execute( - select(EventOrderItem) - .options(selectinload(EventOrderItem.recipe)) - .where(EventOrderItem.event_order_id == order_id) - ) - items = items_result.scalars().all() - - # Aggregate ingredient quantities across all recipes - total_ingredients: dict[int, float] = defaultdict(float) - recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}] - - for item in items: - recipe = item.recipe - if not recipe: - continue - - # For dish: multiplier = quantity (servings) / output_qty - # For component: multiplier = quantity (batches) - if recipe.recipe_type == "component": - multiplier = float(item.quantity) # each item.quantity = number of batches - else: - # Dishes are always portioned with batch_portions=1 - multiplier = float(item.quantity) / (recipe.batch_portions or 1) - - ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db) - for ing_id, qty in ings.items(): - total_ingredients[ing_id] += qty - recipe_breakdown[ing_id].append({ - "recipe_name": recipe.name, - "quantity": round(qty, 3), - }) - - if not total_ingredients: - return {"items": [], "by_supplier": {}} - - # Load ingredient details and sources - ing_ids = list(total_ingredients.keys()) - ing_result = await db.execute( - select(Ingredient) - .options( - selectinload(Ingredient.category), - selectinload(Ingredient.sources).selectinload(IngredientSource.supplier), - ) - .where(Ingredient.id.in_(ing_ids)) - ) - ingredients = {ing.id: ing for ing in ing_result.scalars().all()} - - shopping_items = [] - by_supplier: dict[str, list] = defaultdict(list) - - for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]): - ing = ingredients.get(ing_id) - if not ing: - continue - - # Quantity already yield-adjusted during collection - adjusted_qty = total_qty - - # Source info - sources = [] - for src in (ing.sources or []): - pack_total = None - suggested_packs = None - cost_per_pack = None - - if src.pack_quantity and src.unit_size and src.unit_size_type: - pack_in_std = convert_to_standard( - Decimal(str(src.pack_quantity)) * src.unit_size, - src.unit_size_type, - ing.standard_unit, - ) - if pack_in_std and float(pack_in_std) > 0: - pack_total = float(pack_in_std) - suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0) - if src.latest_unit_price: - cost_per_pack = float(src.latest_unit_price) - - source_info = { - "supplier_id": src.supplier_id, - "supplier_name": src.supplier.name if src.supplier else "", - "product_code": src.product_code, - "pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None, - "pack_total_std_unit": pack_total, - "suggested_packs": suggested_packs, - "cost_per_pack": cost_per_pack, - "subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None, - } - sources.append(source_info) - - if group_by_supplier: - supplier_name = src.supplier.name if src.supplier else "Unknown" - by_supplier[supplier_name].append({ - "ingredient_name": ing.name, - "quantity_needed": round(adjusted_qty, 3), - "unit": ing.standard_unit, - **source_info, - }) - - item_data = { - "ingredient_id": ing.id, - "ingredient_name": ing.name, - "category": ing.category.name if ing.category else "Other", - "total_quantity": round(total_qty, 3), - "adjusted_quantity": round(adjusted_qty, 3), - "unit": ing.standard_unit, - "sources": sources, - "recipe_breakdown": recipe_breakdown.get(ing_id, []), - } - shopping_items.append(item_data) - - # Sort by category - shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"])) - - return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}} - - -# ── Generate Purchase Orders ───────────────────────────────────────────────── - -@router.post("/{order_id}/generate-po") -async def generate_purchase_orders( - order_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Generate purchase orders from shopping list, grouped by supplier.""" - from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem - - order = await _get_order(order_id, user.kitchen_id, db) - - # Get shopping list grouped by supplier - items_result = await db.execute( - select(EventOrderItem) - .options(selectinload(EventOrderItem.recipe)) - .where(EventOrderItem.event_order_id == order_id) - ) - items = items_result.scalars().all() - - # Aggregate ingredient quantities - total_ingredients: dict[int, float] = defaultdict(float) - for item in items: - recipe = item.recipe - if not recipe: - continue - if recipe.recipe_type == "component": - multiplier = float(item.quantity) - else: - multiplier = float(item.quantity) / (recipe.batch_portions or 1) - ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db) - for ing_id, qty in ings.items(): - total_ingredients[ing_id] += qty - - if not total_ingredients: - raise HTTPException(400, "No ingredients to order") - - # Load ingredients with sources - from sqlalchemy.orm import selectinload as si - ing_result = await db.execute( - select(Ingredient) - .options( - selectinload(Ingredient.sources).selectinload(IngredientSource.supplier), - ) - .where(Ingredient.id.in_(list(total_ingredients.keys()))) - ) - ingredients = {ing.id: ing for ing in ing_result.scalars().all()} - - # Group by supplier: {supplier_id: [{ingredient, qty, source}]} - supplier_lines: dict[int, list] = defaultdict(list) - unmapped = [] - - for ing_id, total_qty in total_ingredients.items(): - ing = ingredients.get(ing_id) - if not ing: - continue - - # Quantity already yield-adjusted during collection - adjusted_qty = total_qty - - # Pick the most recent source (by latest_invoice_date) - best_source = None - for src in (ing.sources or []): - if best_source is None or (src.latest_invoice_date and ( - not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date - )): - best_source = src - - if best_source and best_source.supplier_id: - pack_total = None - suggested_packs = 1 - if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type: - pack_in_std = convert_to_standard( - Decimal(str(best_source.pack_quantity)) * best_source.unit_size, - best_source.unit_size_type, - ing.standard_unit, - ) - if pack_in_std and float(pack_in_std) > 0: - pack_total = float(pack_in_std) - suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0) - - supplier_lines[best_source.supplier_id].append({ - "ingredient": ing, - "source": best_source, - "quantity": max(suggested_packs, 1), - "unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0, - }) - else: - unmapped.append(ing.name) - - # Create one PO per supplier - created_pos = [] - for supplier_id, lines in supplier_lines.items(): - total = sum(l["quantity"] * l["unit_price"] for l in lines) - po = PurchaseOrder( - kitchen_id=user.kitchen_id, - supplier_id=supplier_id, - order_date=order.event_date or date.today(), - order_type="itemised", - status="DRAFT", - total_amount=Decimal(str(round(total, 2))), - notes=f"Auto-generated from event order: {order.name}", - created_by=user.id, - ) - db.add(po) - await db.flush() - - for idx, line in enumerate(lines): - line_total = round(line["quantity"] * line["unit_price"], 2) - po_line = PurchaseOrderLineItem( - purchase_order_id=po.id, - kitchen_id=user.kitchen_id, - product_code=line["source"].product_code, - description=line["ingredient"].name, - unit=line["ingredient"].standard_unit, - unit_price=Decimal(str(line["unit_price"])), - quantity=Decimal(str(line["quantity"])), - total=Decimal(str(line_total)), - line_number=idx + 1, - source="event_order", - ) - db.add(po_line) - - created_pos.append({"id": po.id, "supplier_id": supplier_id}) - - await db.commit() - - return { - "created": len(created_pos), - "purchase_orders": created_pos, - "unmapped_ingredients": unmapped, - } - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder: - result = await db.execute( - select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id) - ) - order = result.scalar_one_or_none() - if not order: - raise HTTPException(404, "Event order not found") - return order diff --git a/backend/api/external.py b/backend/api/external.py deleted file mode 100644 index 860fe5a..0000000 --- a/backend/api/external.py +++ /dev/null @@ -1,497 +0,0 @@ -""" -Internal API for in-house apps — API key authentication, dish recipe data, -food flag listings, menus. Prefix: /api/external/ -""" -import os -import logging -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Header, Query -from fastapi.responses import FileResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from sqlalchemy.orm import selectinload - -from database import get_db -from models.settings import KitchenSettings -from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeImage -from models.menu import Menu, MenuDivision, MenuItem -from models.ingredient import Ingredient -from models.food_flag import FoodFlagCategory, FoodFlag -from api.food_flags import compute_recipe_flags -from api.menus import _compute_staleness - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def get_kitchen_from_api_key( - x_api_key: str = Header(..., alias="X-API-Key"), - db: AsyncSession = Depends(get_db), -) -> KitchenSettings: - """Authenticate via API key and return the kitchen settings.""" - if not x_api_key: - raise HTTPException(401, "Missing X-API-Key header") - - result = await db.execute( - select(KitchenSettings).where( - KitchenSettings.api_key == x_api_key, - KitchenSettings.api_key_enabled == True, - ) - ) - settings = result.scalar_one_or_none() - if not settings: - raise HTTPException(401, "Invalid or disabled API key") - return settings - - -@router.get("/recipes/dishes") -async def list_dish_recipes( - include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), - include_costs: bool = Query(False), - exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"), - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """List non-archived dish recipes for external consumption.""" - query = ( - select(Recipe) - .options( - selectinload(Recipe.menu_section), - selectinload(Recipe.images), - ) - .where( - Recipe.kitchen_id == kitchen.kitchen_id, - Recipe.recipe_type == "dish", - Recipe.is_archived == False, - ) - .order_by(Recipe.name) - ) - result = await db.execute(query) - recipes = result.scalars().all() - - exclude_flag_ids = set() - if exclude_flags: - exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} - - items = [] - for r in recipes: - # Get flags - flags = await compute_recipe_flags(r.id, kitchen.kitchen_id, db) - active_flags = [f for f in flags if f.is_active] - - # Check exclude filter - if exclude_flag_ids: - recipe_flag_ids = {f.food_flag_id for f in active_flags} - if recipe_flag_ids & exclude_flag_ids: - continue - - flag_data = [ - { - "id": f.food_flag_id, - "name": f.flag_name, - "code": f.flag_code, - "icon": f.flag_icon, - "category": f.category_name, - "propagation": f.propagation_type, - "excludable": f.excludable_on_request, - } - for f in active_flags - ] - - item = { - "id": r.id, - "name": r.name, - "description": r.description, - "menu_section": r.menu_section.name if r.menu_section else None, - "prep_time_minutes": r.prep_time_minutes, - "cook_time_minutes": r.cook_time_minutes, - "flags": flag_data, - "images": [ - {"id": img.id, "caption": img.caption, "image_type": img.image_type} - for img in (r.images or []) - ], - } - - # Include costs if requested - if include_costs: - from api.recipes import _calc_recipe_cost - cost_data = await _calc_recipe_cost(r.id, db) - item["cost_per_portion"] = cost_data.get("cost_per_portion") - item["total_cost"] = cost_data.get("total_cost_recent") - - # Include ingredients if requested - if include_ingredients != "none": - item["ingredients"] = await _get_recipe_ingredients(r.id, kitchen.kitchen_id, db, include_ingredients) - - items.append(item) - - return items - - -@router.get("/recipes/plated") -async def list_plated_recipes_compat( - include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), - include_costs: bool = Query(False), - exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"), - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """Backward-compatible alias for /recipes/dishes.""" - return await list_dish_recipes(include_ingredients, include_costs, exclude_flags, kitchen, db) - - -@router.get("/recipes/{recipe_id}") -async def get_dish_recipe( - recipe_id: int, - include_ingredients: str = Query("none", regex="^(none|flat|nested)$"), - include_costs: bool = Query(False), - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """Get a single dish recipe for external consumption.""" - result = await db.execute( - select(Recipe) - .options(selectinload(Recipe.menu_section), selectinload(Recipe.images)) - .where( - Recipe.id == recipe_id, - Recipe.kitchen_id == kitchen.kitchen_id, - Recipe.recipe_type == "dish", - Recipe.is_archived == False, - ) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - - flags = await compute_recipe_flags(recipe.id, kitchen.kitchen_id, db) - active_flags = [f for f in flags if f.is_active] - - item = { - "id": recipe.id, - "name": recipe.name, - "description": recipe.description, - "menu_section": recipe.menu_section.name if recipe.menu_section else None, - "prep_time_minutes": recipe.prep_time_minutes, - "cook_time_minutes": recipe.cook_time_minutes, - "flags": [ - { - "id": f.food_flag_id, - "name": f.flag_name, - "code": f.flag_code, - "icon": f.flag_icon, - "category": f.category_name, - "propagation": f.propagation_type, - "excludable": f.excludable_on_request, - } - for f in active_flags - ], - "images": [ - {"id": img.id, "caption": img.caption, "image_type": img.image_type} - for img in (recipe.images or []) - ], - } - - if include_costs: - from api.recipes import _calc_recipe_cost - cost_data = await _calc_recipe_cost(recipe.id, db) - item["cost_per_portion"] = cost_data.get("cost_per_portion") - item["total_cost"] = cost_data.get("total_cost_recent") - - if include_ingredients != "none": - item["ingredients"] = await _get_recipe_ingredients(recipe.id, kitchen.kitchen_id, db, include_ingredients) - - return item - - -@router.get("/food-flags") -async def list_food_flags( - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """List all flag categories and flags for external apps.""" - result = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == kitchen.kitchen_id) - .order_by(FoodFlagCategory.sort_order) - ) - categories = result.scalars().all() - return [ - { - "id": cat.id, - "name": cat.name, - "propagation_type": cat.propagation_type, - "flags": [ - {"id": f.id, "name": f.name, "code": f.code, "icon": f.icon} - for f in sorted(cat.flags, key=lambda x: x.sort_order) - ], - } - for cat in categories - ] - - -async def _get_recipe_ingredients(recipe_id: int, kitchen_id: int, db: AsyncSession, mode: str) -> list: - """Get ingredient list for external API — flat (consolidated) or nested (sub-recipe breakdown).""" - if mode == "flat": - # Consolidated list - from api.event_orders import _collect_ingredients_for_recipe - ing_qtys = await _collect_ingredients_for_recipe(recipe_id, 1.0, db) - if not ing_qtys: - return [] - - result = await db.execute( - select(Ingredient).where(Ingredient.id.in_(list(ing_qtys.keys()))) - ) - ingredients = {ing.id: ing for ing in result.scalars().all()} - - return [ - { - "ingredient_id": ing_id, - "name": ingredients[ing_id].name if ing_id in ingredients else "?", - "quantity": round(qty, 3), - "unit": ingredients[ing_id].standard_unit if ing_id in ingredients else "", - } - for ing_id, qty in sorted(ing_qtys.items()) - ] - - elif mode == "nested": - # Show sub-recipe breakdown - ri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient)) - .where(RecipeIngredient.recipe_id == recipe_id) - .order_by(RecipeIngredient.sort_order) - ) - direct = [ - { - "ingredient_id": ri.ingredient_id, - "name": ri.ingredient.name if ri.ingredient else "?", - "quantity": float(ri.quantity), - "unit": ri.ingredient.standard_unit if ri.ingredient else "", - "source": "direct", - } - for ri in ri_result.scalars().all() - ] - - sr_result = await db.execute( - select(RecipeSubRecipe) - .options(selectinload(RecipeSubRecipe.child_recipe)) - .where(RecipeSubRecipe.parent_recipe_id == recipe_id) - ) - sub_recipe_ings = [] - for sr in sr_result.scalars().all(): - child = sr.child_recipe - if not child: - continue - cri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient)) - .where(RecipeIngredient.recipe_id == child.id) - ) - # Use unified output qty for bulk/portioned child recipes - child_output_qty = float(child.batch_yield_qty) if child.batch_output_type == "bulk" and child.batch_yield_qty else (child.batch_portions or 1) - child_output_unit = child.batch_yield_unit if child.batch_output_type == "bulk" and child.batch_yield_unit else "portion" - # Convert portions_needed to child output unit if different unit was used - needed = float(sr.portions_needed) - needed_unit = sr.portions_needed_unit or child_output_unit - if needed_unit != child_output_unit: - _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} - if needed_unit in _bases and child_output_unit in _bases: - needed = needed * _bases[needed_unit] / _bases[child_output_unit] - scale = needed / child_output_qty - for cri in cri_result.scalars().all(): - sub_recipe_ings.append({ - "ingredient_id": cri.ingredient_id, - "name": cri.ingredient.name if cri.ingredient else "?", - "quantity": round(float(cri.quantity) * scale, 3), - "unit": cri.ingredient.standard_unit if cri.ingredient else "", - "source": f"sub-recipe: {child.name}", - }) - - return direct + sub_recipe_ings - - return [] - - -# ── Menu Endpoints ─────────────────────────────────────────────────────────── - -@router.get("/menus") -async def list_menus_external( - exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude items containing those allergens"), - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """List active menus with divisions and items served from snapshots.""" - result = await db.execute( - select(Menu) - .options( - selectinload(Menu.divisions).selectinload(MenuDivision.items), - selectinload(Menu.items), - ) - .where(Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True) - .order_by(Menu.sort_order) - ) - menus = result.scalars().all() - - exclude_flag_ids = set() - if exclude_flags: - exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} - - menus_data = [] - for menu in menus: - all_items = [i for i in (menu.items or []) if i.recipe_id is not None] - staleness = await _compute_staleness(all_items, db) - - divisions_data = [] - for div in sorted(menu.divisions or [], key=lambda d: d.sort_order): - div_items = sorted( - [i for i in all_items if i.division_id == div.id], - key=lambda i: i.sort_order, - ) - items_data = [] - for item in div_items: - snapshot = item.snapshot_json or {} - confirmed_flags = snapshot.get("confirmed_flags", []) - - # Apply exclude filter - if exclude_flag_ids: - item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")} - if item_flag_ids & exclude_flag_ids: - continue - - stale_info = staleness.get(item.id, {"is_stale": False}) - - items_data.append({ - "id": item.id, - "display_name": snapshot.get("display_name", item.display_name), - "description": snapshot.get("description", item.description), - "price": snapshot.get("price", str(item.price) if item.price else None), - "flags": [ - {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"), - "category": f.get("category"), "excludable": f.get("excludable", False)} - for f in confirmed_flags - ], - "is_stale": stale_info.get("is_stale", False), - "has_image": bool(item.image_path), - }) - - if items_data: - divisions_data.append({ - "name": div.name, - "items": items_data, - }) - - menus_data.append({ - "id": menu.id, - "name": menu.name, - "description": menu.description, - "divisions": divisions_data, - }) - - return menus_data - - -@router.get("/menus/{menu_id}") -async def get_menu_external( - menu_id: int, - exclude_flags: Optional[str] = Query(None), - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """Single menu detail for external consumption.""" - result = await db.execute( - select(Menu) - .options( - selectinload(Menu.divisions).selectinload(MenuDivision.items), - selectinload(Menu.items), - ) - .where( - Menu.id == menu_id, - Menu.kitchen_id == kitchen.kitchen_id, - Menu.is_active == True, - ) - ) - menu = result.scalar_one_or_none() - if not menu: - raise HTTPException(404, "Menu not found") - - exclude_flag_ids = set() - if exclude_flags: - exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()} - - all_items = [i for i in (menu.items or []) if i.recipe_id is not None] - staleness = await _compute_staleness(all_items, db) - - divisions_data = [] - for div in sorted(menu.divisions or [], key=lambda d: d.sort_order): - div_items = sorted( - [i for i in all_items if i.division_id == div.id], - key=lambda i: i.sort_order, - ) - items_data = [] - for item in div_items: - snapshot = item.snapshot_json or {} - confirmed_flags = snapshot.get("confirmed_flags", []) - - if exclude_flag_ids: - item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")} - if item_flag_ids & exclude_flag_ids: - continue - - stale_info = staleness.get(item.id, {"is_stale": False}) - - items_data.append({ - "id": item.id, - "display_name": snapshot.get("display_name", item.display_name), - "description": snapshot.get("description", item.description), - "price": snapshot.get("price", str(item.price) if item.price else None), - "flags": [ - {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"), - "category": f.get("category"), "excludable": f.get("excludable", False)} - for f in confirmed_flags - ], - "is_stale": stale_info.get("is_stale", False), - "has_image": bool(item.image_path), - }) - - if items_data: - divisions_data.append({ - "name": div.name, - "items": items_data, - }) - - return { - "id": menu.id, - "name": menu.name, - "description": menu.description, - "divisions": divisions_data, - } - - -@router.get("/menus/{menu_id}/items/{item_id}/image") -async def serve_menu_item_image_external( - menu_id: int, - item_id: int, - kitchen: KitchenSettings = Depends(get_kitchen_from_api_key), - db: AsyncSession = Depends(get_db), -): - """Serve a menu item image via API key authentication.""" - result = await db.execute( - select(MenuItem) - .join(Menu, MenuItem.menu_id == Menu.id) - .where( - MenuItem.id == item_id, - MenuItem.menu_id == menu_id, - Menu.kitchen_id == kitchen.kitchen_id, - ) - ) - item = result.scalar_one_or_none() - if not item or not item.image_path: - raise HTTPException(404, "Image not found") - - if not os.path.exists(item.image_path): - raise HTTPException(404, "Image file not found") - - return FileResponse(item.image_path) diff --git a/backend/api/field_mappings.py b/backend/api/field_mappings.py deleted file mode 100644 index 2ecabdc..0000000 --- a/backend/api/field_mappings.py +++ /dev/null @@ -1,205 +0,0 @@ -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.field_mapping import ( - FieldMapping, - AZURE_INVOICE_FIELDS, - AZURE_LINE_ITEM_FIELDS, - TARGET_INVOICE_FIELDS, - TARGET_LINE_ITEM_FIELDS -) -from auth import get_current_user, require_cap - -router = APIRouter() - - -class FieldMappingResponse(BaseModel): - id: int - supplier_id: int | None - source_field: str - target_field: str - field_type: str - transform: str - priority: int - - class Config: - from_attributes = True - - -class FieldMappingCreate(BaseModel): - supplier_id: Optional[int] = None - source_field: str - target_field: str - field_type: str = "invoice" - transform: str = "direct" - priority: int = 0 - - -class FieldMappingUpdate(BaseModel): - source_field: Optional[str] = None - target_field: Optional[str] = None - field_type: Optional[str] = None - transform: Optional[str] = None - priority: Optional[int] = None - - -class FieldOptionsResponse(BaseModel): - azure_invoice_fields: list[str] - azure_line_item_fields: list[str] - target_invoice_fields: list[str] - target_line_item_fields: list[str] - - -@router.get("/options", response_model=FieldOptionsResponse) -async def get_field_options(): - """Get available field names for creating mappings""" - return FieldOptionsResponse( - azure_invoice_fields=AZURE_INVOICE_FIELDS, - azure_line_item_fields=AZURE_LINE_ITEM_FIELDS, - target_invoice_fields=TARGET_INVOICE_FIELDS, - target_line_item_fields=TARGET_LINE_ITEM_FIELDS - ) - - -@router.get("/", response_model=list[FieldMappingResponse]) -async def list_field_mappings( - supplier_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List field mappings for the current kitchen, optionally filtered by supplier""" - query = select(FieldMapping).where( - FieldMapping.kitchen_id == current_user.kitchen_id - ) - - if supplier_id is not None: - # Get mappings for specific supplier OR global (supplier_id=null) - query = query.where( - (FieldMapping.supplier_id == supplier_id) | - (FieldMapping.supplier_id.is_(None)) - ) - - query = query.order_by(FieldMapping.priority.desc(), FieldMapping.id) - - result = await db.execute(query) - mappings = result.scalars().all() - - return [ - FieldMappingResponse( - id=m.id, - supplier_id=m.supplier_id, - source_field=m.source_field, - target_field=m.target_field, - field_type=m.field_type, - transform=m.transform, - priority=m.priority - ) - for m in mappings - ] - - -@router.post("/", response_model=FieldMappingResponse) -async def create_field_mapping( - mapping: FieldMappingCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Create a new field mapping""" - # Validate target field - valid_targets = ( - TARGET_INVOICE_FIELDS if mapping.field_type == "invoice" - else TARGET_LINE_ITEM_FIELDS - ) - if mapping.target_field not in valid_targets: - raise HTTPException( - status_code=400, - detail=f"Invalid target field. Valid options: {valid_targets}" - ) - - new_mapping = FieldMapping( - kitchen_id=current_user.kitchen_id, - supplier_id=mapping.supplier_id, - source_field=mapping.source_field, - target_field=mapping.target_field, - field_type=mapping.field_type, - transform=mapping.transform, - priority=mapping.priority - ) - - db.add(new_mapping) - await db.commit() - await db.refresh(new_mapping) - - return FieldMappingResponse( - id=new_mapping.id, - supplier_id=new_mapping.supplier_id, - source_field=new_mapping.source_field, - target_field=new_mapping.target_field, - field_type=new_mapping.field_type, - transform=new_mapping.transform, - priority=new_mapping.priority - ) - - -@router.patch("/{mapping_id}", response_model=FieldMappingResponse) -async def update_field_mapping( - mapping_id: int, - update: FieldMappingUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update a field mapping""" - result = await db.execute( - select(FieldMapping).where( - FieldMapping.id == mapping_id, - FieldMapping.kitchen_id == current_user.kitchen_id - ) - ) - mapping = result.scalar_one_or_none() - if not mapping: - raise HTTPException(status_code=404, detail="Field mapping not found") - - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(mapping, field, value) - - await db.commit() - await db.refresh(mapping) - - return FieldMappingResponse( - id=mapping.id, - supplier_id=mapping.supplier_id, - source_field=mapping.source_field, - target_field=mapping.target_field, - field_type=mapping.field_type, - transform=mapping.transform, - priority=mapping.priority - ) - - -@router.delete("/{mapping_id}") -async def delete_field_mapping( - mapping_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete a field mapping""" - result = await db.execute( - select(FieldMapping).where( - FieldMapping.id == mapping_id, - FieldMapping.kitchen_id == current_user.kitchen_id - ) - ) - mapping = result.scalar_one_or_none() - if not mapping: - raise HTTPException(status_code=404, detail="Field mapping not found") - - await db.delete(mapping) - await db.commit() - - return {"message": "Field mapping deleted"} diff --git a/backend/api/food_flags.py b/backend/api/food_flags.py deleted file mode 100644 index 3a9c594..0000000 --- a/backend/api/food_flags.py +++ /dev/null @@ -1,2309 +0,0 @@ -""" -Food Flag API — categories, flags, line item flagging + latching, recipe flag propagation, -allergen keyword suggestions, and label OCR scanning. -""" -import logging -import os -import re -import uuid -from datetime import datetime -from typing import Optional - -import aiofiles -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete, func -from sqlalchemy.orm import selectinload -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.food_flag import FoodFlagCategory, FoodFlag, LineItemFlag, RecipeFlag, RecipeFlagOverride, AllergenKeyword, BrakesProductCache -from models.ingredient import Ingredient, IngredientFlag, IngredientFlagNone, IngredientFlagDismissal -from models.line_item import LineItem -from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeTextFlagDismissal -from models.settings import KitchenSettings -from auth import get_current_user, require_cap - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# ── Pydantic schemas ───────────────────────────────────────────────────────── - -class CategoryCreate(BaseModel): - name: str - propagation_type: str = "contains" # "contains" | "suitable_for" - required: bool = False - sort_order: int = 0 - -class CategoryUpdate(BaseModel): - name: Optional[str] = None - propagation_type: Optional[str] = None - required: Optional[bool] = None - sort_order: Optional[int] = None - -class FlagCreate(BaseModel): - category_id: int - name: str - code: Optional[str] = None - icon: Optional[str] = None - sort_order: int = 0 - -class FlagUpdate(BaseModel): - name: Optional[str] = None - code: Optional[str] = None - icon: Optional[str] = None - sort_order: Optional[int] = None - -class FlagResponse(BaseModel): - id: int - name: str - code: Optional[str] = None - icon: Optional[str] = None - sort_order: int = 0 - category_id: int - category_name: str = "" - propagation_type: str = "contains" - -class CategoryResponse(BaseModel): - id: int - name: str - propagation_type: str - required: bool = False - sort_order: int - flags: list[FlagResponse] = [] - -class LineItemFlagSet(BaseModel): - food_flag_ids: list[int] - -class LineItemFlagResponse(BaseModel): - id: int - food_flag_id: int - flag_name: str = "" - flag_code: Optional[str] = None - category_name: str = "" - -class RecipeFlagState(BaseModel): - food_flag_id: int - flag_name: str = "" - flag_code: Optional[str] = None - flag_icon: Optional[str] = None - category_id: int - category_name: str = "" - propagation_type: str = "contains" - source_type: str = "auto" # "auto" | "manual" - is_active: bool = True - excludable_on_request: bool = False - source_ingredients: list[str] = [] # ingredient names that contribute this flag - -class RecipeFlagOverrideLog(BaseModel): - id: int - food_flag_id: int - flag_name: str = "" - action: str - note: str - username: str = "" - created_at: str = "" - -class OverrideRequest(BaseModel): - note: str - -class ManualFlagAdd(BaseModel): - food_flag_id: int - -class ExcludableToggle(BaseModel): - note: str - -class MatrixCell(BaseModel): - has_flag: bool = False - is_unassessed: bool = False - is_none: bool = False # "None apply" set for this flag's category - has_open_suggestion: bool = False # unreviewed allergen suggestion exists - -class MatrixIngredient(BaseModel): - ingredient_id: int - ingredient_name: str - is_sub_recipe: bool = False - sub_recipe_name: Optional[str] = None - flags: dict[int, MatrixCell] = {} # food_flag_id -> cell state - - -# ── Seed defaults ──────────────────────────────────────────────────────────── - -DEFAULT_ALLERGY_FLAGS = [ - ("Celery", "Ce"), ("Gluten", "Gl"), ("Crustaceans", "Cr"), ("Eggs", "Eg"), - ("Fish", "Fi"), ("Lupin", "Lu"), ("Milk", "Mi"), ("Molluscs", "Mo"), - ("Mustard", "Mu"), ("Tree Nuts", "TN"), ("Peanuts", "Pn"), ("Sesame", "Se"), - ("Soya", "So"), ("Sulphites", "Su"), -] - -DEFAULT_DIETARY_FLAGS = [ - ("Vegetarian", "V"), ("Vegan", "Ve"), ("Pescatarian", "Pe"), ("Gluten-Free", "GF"), -] - - -@router.post("/seed-defaults") -async def seed_default_flags( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Seed the standard 14 UK allergens + 4 dietary flags. Skips any that already exist.""" - import traceback - try: - from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS - - kid = user.kitchen_id - created_cats = 0 - created_flags = 0 - seeded_keywords = 0 - - # --- Allergy category --- - result = await db.execute( - select(FoodFlagCategory).where( - FoodFlagCategory.kitchen_id == kid, - FoodFlagCategory.name == "Allergy", - ) - ) - allergy_cat = result.scalar_one_or_none() - if not allergy_cat: - allergy_cat = FoodFlagCategory( - kitchen_id=kid, name="Allergy", propagation_type="contains", - required=True, sort_order=0, - ) - db.add(allergy_cat) - await db.flush() - created_cats += 1 - - for i, (name, code) in enumerate(DEFAULT_ALLERGY_FLAGS): - exists = await db.execute( - select(FoodFlag).where( - FoodFlag.kitchen_id == kid, FoodFlag.name == name, - ) - ) - if exists.scalar_one_or_none(): - continue - db.add(FoodFlag( - category_id=allergy_cat.id, kitchen_id=kid, - name=name, code=code, sort_order=i, - )) - created_flags += 1 - - # --- Dietary category --- - result = await db.execute( - select(FoodFlagCategory).where( - FoodFlagCategory.kitchen_id == kid, - FoodFlagCategory.name == "Dietary", - ) - ) - dietary_cat = result.scalar_one_or_none() - if not dietary_cat: - dietary_cat = FoodFlagCategory( - kitchen_id=kid, name="Dietary", propagation_type="suitable_for", - required=False, sort_order=1, - ) - db.add(dietary_cat) - await db.flush() - created_cats += 1 - - for i, (name, code) in enumerate(DEFAULT_DIETARY_FLAGS): - exists = await db.execute( - select(FoodFlag).where( - FoodFlag.kitchen_id == kid, FoodFlag.name == name, - ) - ) - if exists.scalar_one_or_none(): - continue - db.add(FoodFlag( - category_id=dietary_cat.id, kitchen_id=kid, - name=name, code=code, sort_order=i, - )) - created_flags += 1 - - await db.flush() - - # --- Seed allergen keywords --- - # Flush new flags first, then seed keywords separately to avoid autoflush conflicts - await db.commit() - - # Re-fetch all flags for keyword seeding (clean session, no pending objects) - for flag_name, keywords in ALLERGEN_KEYWORDS.items(): - flag_result = await db.execute( - select(FoodFlag).where( - FoodFlag.kitchen_id == kid, FoodFlag.name == flag_name, - ) - ) - flag = flag_result.scalar_one_or_none() - if not flag: - continue - # Get all existing keywords for this flag - existing_result = await db.execute( - select(AllergenKeyword.keyword).where( - AllergenKeyword.kitchen_id == kid, - AllergenKeyword.food_flag_id == flag.id, - ) - ) - existing_keywords = {row[0] for row in existing_result.all()} - - # Add only keywords that don't already exist (preserves manual entries) - seen: set[str] = set() - for kw_str in keywords: - kw = kw_str.lower() - if kw in seen or kw in existing_keywords: - continue - seen.add(kw) - db.add(AllergenKeyword( - kitchen_id=kid, food_flag_id=flag.id, - keyword=kw, is_default=True, - )) - seeded_keywords += 1 - # Flush after each flag to avoid cross-flag autoflush issues - await db.flush() - - await db.commit() - logger.info(f"Seeded defaults for kitchen {kid}: {created_cats} categories, {created_flags} flags, {seeded_keywords} keywords") - return { - "ok": True, - "created_categories": created_cats, - "created_flags": created_flags, - "seeded_keywords": seeded_keywords, - } - except Exception as e: - logger.error(f"seed-defaults failed: {e}\n{traceback.format_exc()}") - raise HTTPException(500, detail=f"Seed failed: {str(e)}") - - -# ── Category endpoints ─────────────────────────────────────────────────────── - -@router.get("/categories") -async def list_categories( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == user.kitchen_id) - .order_by(FoodFlagCategory.sort_order, FoodFlagCategory.name) - ) - cats = result.scalars().all() - return [ - CategoryResponse( - id=c.id, - name=c.name, - propagation_type=c.propagation_type, - required=c.required, - sort_order=c.sort_order, - flags=[ - FlagResponse( - id=f.id, name=f.name, code=f.code, icon=f.icon, - sort_order=f.sort_order, category_id=c.id, - category_name=c.name, propagation_type=c.propagation_type, - ) - for f in sorted(c.flags, key=lambda x: (x.sort_order, x.name)) - ], - ) - for c in cats - ] - - -@router.post("/categories") -async def create_category( - data: CategoryCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - if data.propagation_type not in ("contains", "suitable_for"): - raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'") - cat = FoodFlagCategory( - kitchen_id=user.kitchen_id, - name=data.name, - propagation_type=data.propagation_type, - required=data.required, - sort_order=data.sort_order, - ) - db.add(cat) - await db.commit() - await db.refresh(cat) - return CategoryResponse(id=cat.id, name=cat.name, propagation_type=cat.propagation_type, required=cat.required, sort_order=cat.sort_order) - - -@router.patch("/categories/{cat_id}") -async def update_category( - cat_id: int, - data: CategoryUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(FoodFlagCategory).where( - FoodFlagCategory.id == cat_id, - FoodFlagCategory.kitchen_id == user.kitchen_id, - ) - ) - cat = result.scalar_one_or_none() - if not cat: - raise HTTPException(404, "Category not found") - if data.name is not None: - cat.name = data.name - if data.propagation_type is not None: - if data.propagation_type not in ("contains", "suitable_for"): - raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'") - cat.propagation_type = data.propagation_type - if data.required is not None: - cat.required = data.required - if data.sort_order is not None: - cat.sort_order = data.sort_order - await db.commit() - return {"ok": True} - - -@router.delete("/categories/{cat_id}") -async def delete_category( - cat_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(FoodFlagCategory).where( - FoodFlagCategory.id == cat_id, - FoodFlagCategory.kitchen_id == user.kitchen_id, - ) - ) - cat = result.scalar_one_or_none() - if not cat: - raise HTTPException(404, "Category not found") - # Clean up IngredientFlagNone records for this category - await db.execute( - delete(IngredientFlagNone).where(IngredientFlagNone.category_id == cat_id) - ) - try: - await db.delete(cat) - await db.commit() - except Exception as e: - await db.rollback() - logger.error(f"Failed to delete category {cat_id}: {e}") - raise HTTPException(500, f"Failed to delete category: {str(e)}") - return {"ok": True} - - -# ── Flag CRUD ──────────────────────────────────────────────────────────────── - -@router.post("/flags") -async def create_flag( - data: FlagCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - # Verify category belongs to kitchen - cat = await db.execute( - select(FoodFlagCategory).where( - FoodFlagCategory.id == data.category_id, - FoodFlagCategory.kitchen_id == user.kitchen_id, - ) - ) - if not cat.scalar_one_or_none(): - raise HTTPException(404, "Category not found") - - flag = FoodFlag( - category_id=data.category_id, - kitchen_id=user.kitchen_id, - name=data.name, - code=data.code, - icon=data.icon, - sort_order=data.sort_order, - ) - db.add(flag) - await db.commit() - await db.refresh(flag) - return FlagResponse( - id=flag.id, name=flag.name, code=flag.code, icon=flag.icon, - sort_order=flag.sort_order, category_id=data.category_id, - ) - - -@router.patch("/flags/{flag_id}") -async def update_flag( - flag_id: int, - data: FlagUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(FoodFlag).where( - FoodFlag.id == flag_id, - FoodFlag.kitchen_id == user.kitchen_id, - ) - ) - flag = result.scalar_one_or_none() - if not flag: - raise HTTPException(404, "Flag not found") - if data.name is not None: - flag.name = data.name - if data.code is not None: - flag.code = data.code - if data.icon is not None: - flag.icon = data.icon - if data.sort_order is not None: - flag.sort_order = data.sort_order - await db.commit() - return {"ok": True} - - -@router.delete("/flags/{flag_id}") -async def delete_flag( - flag_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(FoodFlag).where( - FoodFlag.id == flag_id, - FoodFlag.kitchen_id == user.kitchen_id, - ) - ) - flag = result.scalar_one_or_none() - if not flag: - raise HTTPException(404, "Flag not found") - # Explicitly clean up related records (belt + suspenders alongside CASCADE) - await db.execute(delete(IngredientFlag).where(IngredientFlag.food_flag_id == flag_id)) - await db.execute(delete(RecipeFlag).where(RecipeFlag.food_flag_id == flag_id)) - await db.execute(delete(RecipeFlagOverride).where(RecipeFlagOverride.food_flag_id == flag_id)) - await db.execute(delete(LineItemFlag).where(LineItemFlag.food_flag_id == flag_id)) - try: - await db.delete(flag) - await db.commit() - except Exception as e: - await db.rollback() - logger.error(f"Failed to delete flag {flag_id}: {e}") - raise HTTPException(500, f"Failed to delete flag: {str(e)}") - return {"ok": True} - - -# ── Line Item Flags + Latching ─────────────────────────────────────────────── - -@router.get("/line-items/{line_item_id}/flags") -async def get_line_item_flags( - line_item_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(LineItemFlag) - .options(selectinload(LineItemFlag.food_flag).selectinload(FoodFlag.category)) - .where(LineItemFlag.line_item_id == line_item_id) - ) - flags = result.scalars().all() - return [ - LineItemFlagResponse( - id=f.id, - food_flag_id=f.food_flag_id, - flag_name=f.food_flag.name if f.food_flag else "", - flag_code=f.food_flag.code if f.food_flag else None, - category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "", - ) - for f in flags - ] - - -@router.put("/line-items/{line_item_id}/flags") -async def set_line_item_flags( - line_item_id: int, - data: LineItemFlagSet, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Set flags on a line item (full replacement). Triggers latching to mapped ingredient.""" - # Get the line item to check ingredient_id - li_result = await db.execute(select(LineItem).where(LineItem.id == line_item_id)) - li = li_result.scalar_one_or_none() - if not li: - raise HTTPException(404, "Line item not found") - - # Delete existing line item flags - await db.execute(delete(LineItemFlag).where(LineItemFlag.line_item_id == line_item_id)) - - # Add new flags - for flag_id in data.food_flag_ids: - db.add(LineItemFlag( - line_item_id=line_item_id, - food_flag_id=flag_id, - flagged_by=user.id, - )) - - # Latching: if line item is mapped to an ingredient, auto-create ingredient_flag - if li.ingredient_id: - existing = await db.execute( - select(IngredientFlag).where( - IngredientFlag.ingredient_id == li.ingredient_id, - IngredientFlag.food_flag_id == flag_id, - ) - ) - if not existing.scalar_one_or_none(): - db.add(IngredientFlag( - ingredient_id=li.ingredient_id, - food_flag_id=flag_id, - flagged_by=user.id, - source="latched", - )) - - await db.commit() - return {"ok": True} - - -# ── Recipe Flag Propagation ────────────────────────────────────────────────── - -async def _collect_recipe_ingredient_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]: - """Recursively collect all ingredient IDs used in a recipe (including sub-recipes).""" - if depth > 5: - return [] - - ingredient_ids = [] - - # Direct ingredients - ri_result = await db.execute( - select(RecipeIngredient.ingredient_id).where(RecipeIngredient.recipe_id == recipe_id) - ) - ingredient_ids.extend([r[0] for r in ri_result.fetchall()]) - - # Sub-recipe ingredients (recursive) - sr_result = await db.execute( - select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id) - ) - for (child_id,) in sr_result.fetchall(): - child_ids = await _collect_recipe_ingredient_ids(child_id, db, depth + 1) - ingredient_ids.extend(child_ids) - - return ingredient_ids - - -async def compute_recipe_flags(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[RecipeFlagState]: - """Compute the full flag state for a recipe using ingredient_flags as canonical source.""" - # Get all ingredient IDs (including sub-recipes) - all_ingredient_ids = await _collect_recipe_ingredient_ids(recipe_id, db) - if not all_ingredient_ids: - # Check for manual recipe flags only - manual_result = await db.execute( - select(RecipeFlag) - .options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category)) - .where(RecipeFlag.recipe_id == recipe_id) - ) - manual_flags = manual_result.scalars().all() - return [ - RecipeFlagState( - food_flag_id=rf.food_flag_id, - flag_name=rf.food_flag.name if rf.food_flag else "", - flag_code=rf.food_flag.code if rf.food_flag else None, - flag_icon=rf.food_flag.icon if rf.food_flag else None, - category_id=rf.food_flag.category_id if rf.food_flag else 0, - category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "", - propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains", - source_type=rf.source_type, - is_active=rf.is_active, - excludable_on_request=rf.excludable_on_request, - ) - for rf in manual_flags - ] - - unique_ingredient_ids = list(set(all_ingredient_ids)) - - # Get all ingredient flags for these ingredients - if_result = await db.execute( - select(IngredientFlag) - .options(selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category)) - .where(IngredientFlag.ingredient_id.in_(unique_ingredient_ids)) - ) - ingredient_flags = if_result.scalars().all() - - # Get all food flag categories for this kitchen - cat_result = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == kitchen_id) - ) - categories = cat_result.scalars().all() - - # Build ingredient -> flags mapping - ing_flag_map: dict[int, set[int]] = {} - for ifl in ingredient_flags: - ing_flag_map.setdefault(ifl.ingredient_id, set()).add(ifl.food_flag_id) - - # Build flag_id -> ingredient_names mapping (for source tracing) - flag_source_names: dict[int, list[str]] = {} - # Load ingredient names - ing_names_result = await db.execute( - select(Ingredient.id, Ingredient.name).where(Ingredient.id.in_(unique_ingredient_ids)) - ) - ing_names = {r[0]: r[1] for r in ing_names_result.fetchall()} - - for ifl in ingredient_flags: - flag_source_names.setdefault(ifl.food_flag_id, []).append(ing_names.get(ifl.ingredient_id, "?")) - - # Compute propagated flags - computed_flags: dict[int, RecipeFlagState] = {} - - for cat in categories: - if cat.propagation_type == "contains": - # Union: recipe has flag if ANY ingredient has it - for flag in cat.flags: - for ing_id in unique_ingredient_ids: - if flag.id in ing_flag_map.get(ing_id, set()): - computed_flags[flag.id] = RecipeFlagState( - food_flag_id=flag.id, - flag_name=flag.name, - flag_code=flag.code, - flag_icon=flag.icon, - category_id=cat.id, - category_name=cat.name, - propagation_type="contains", - source_type="auto", - is_active=True, - source_ingredients=flag_source_names.get(flag.id, []), - ) - break - - elif cat.propagation_type == "suitable_for": - # Intersection: recipe has flag only if ALL ingredients have it - for flag in cat.flags: - all_have = True - for ing_id in unique_ingredient_ids: - ing_flags = ing_flag_map.get(ing_id, set()) - # Check if ingredient has ANY flags in this category (if not, it's unassessed) - cat_flag_ids = {f.id for f in cat.flags} - has_any_in_cat = bool(ing_flags & cat_flag_ids) - if not has_any_in_cat or flag.id not in ing_flags: - all_have = False - break - if all_have: - computed_flags[flag.id] = RecipeFlagState( - food_flag_id=flag.id, - flag_name=flag.name, - flag_code=flag.code, - flag_icon=flag.icon, - category_id=cat.id, - category_name=cat.name, - propagation_type="suitable_for", - source_type="auto", - is_active=True, - source_ingredients=[ing_names.get(i, "?") for i in unique_ingredient_ids], - ) - - # Merge with manual recipe flags and apply overrides - rf_result = await db.execute( - select(RecipeFlag) - .options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category)) - .where(RecipeFlag.recipe_id == recipe_id) - ) - recipe_flags = rf_result.scalars().all() - - for rf in recipe_flags: - if rf.source_type == "manual" and rf.food_flag_id not in computed_flags: - computed_flags[rf.food_flag_id] = RecipeFlagState( - food_flag_id=rf.food_flag_id, - flag_name=rf.food_flag.name if rf.food_flag else "", - flag_code=rf.food_flag.code if rf.food_flag else None, - flag_icon=rf.food_flag.icon if rf.food_flag else None, - category_id=rf.food_flag.category_id if rf.food_flag else 0, - category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "", - propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains", - source_type="manual", - is_active=rf.is_active, - excludable_on_request=rf.excludable_on_request, - ) - elif rf.food_flag_id in computed_flags: - # Apply overrides from recipe_flags to computed flags - computed_flags[rf.food_flag_id].is_active = rf.is_active - computed_flags[rf.food_flag_id].excludable_on_request = rf.excludable_on_request - if rf.source_type == "manual": - computed_flags[rf.food_flag_id].source_type = "manual" - - return list(computed_flags.values()) - - -# ── Recipe Flag endpoints ──────────────────────────────────────────────────── - -@router.get("/recipes/{recipe_id}/flags") -async def get_recipe_flags( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Full flag state with source tracing and unassessed ingredient list.""" - # Verify recipe - r = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) - - # Find unassessed ingredients — per required category evaluation - all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db) - unique_ids = list(set(all_ing_ids)) - - # Get required categories with their flag IDs - req_cat_result = await db.execute( - select(FoodFlagCategory.id, FoodFlagCategory.name).where( - FoodFlagCategory.kitchen_id == user.kitchen_id, - FoodFlagCategory.required == True, - ) - ) - required_cats = req_cat_result.all() - - # Build map: category_id → set of flag_ids - cat_flag_map: dict[int, set[int]] = {} - for cat_id, _ in required_cats: - rf_result = await db.execute( - select(FoodFlag.id).where(FoodFlag.category_id == cat_id) - ) - cat_flag_map[cat_id] = set(rf_result.scalars().all()) - - unassessed = [] - if unique_ids and required_cats: - for ing_id in unique_ids: - ing_result = await db.execute( - select(Ingredient.name).where(Ingredient.id == ing_id) - ) - name = ing_result.scalar() - if not name: - continue - - # Get "None" entries for this ingredient - none_result = await db.execute( - select(IngredientFlagNone.category_id).where( - IngredientFlagNone.ingredient_id == ing_id, - ) - ) - none_cat_ids = set(none_result.scalars().all()) - - # Check each required category separately - for cat_id, cat_name in required_cats: - if cat_id in none_cat_ids: - continue # "None" selected for this category - flag_ids = cat_flag_map.get(cat_id, set()) - if not flag_ids: - continue - flag_count = await db.execute( - select(func.count(IngredientFlag.id)).where( - IngredientFlag.ingredient_id == ing_id, - IngredientFlag.food_flag_id.in_(flag_ids), - ) - ) - if flag_count.scalar() == 0: - unassessed.append({"id": ing_id, "name": name, "category": cat_name}) - break # One missing category is enough to flag the ingredient - - # Find ingredients with open (undismissed, unapplied) allergen suggestions - open_suggestion_ings = [] - if unique_ids: - # Load allergen keywords once - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - all_keywords = kw_result.scalars().all() - - if all_keywords: - for ing_id in unique_ids: - # Get ingredient name + product_ingredients - ing_result = await db.execute( - select(Ingredient.name, Ingredient.product_ingredients).where(Ingredient.id == ing_id) - ) - ing_row = ing_result.first() - if not ing_row: - continue - - # Match keywords against name + product ingredients - texts_to_check = [ing_row.name or ""] - if ing_row.product_ingredients: - texts_to_check.append(ing_row.product_ingredients) - combined_text = " ".join(texts_to_check) - - keyword_matches = match_allergen_keywords(combined_text, all_keywords) - if not keyword_matches: - continue - - matched_flag_ids = set(m["flag_id"] for m in keyword_matches) - - # Subtract active flags - active_result = await db.execute( - select(IngredientFlag.food_flag_id).where( - IngredientFlag.ingredient_id == ing_id, - ) - ) - active_ids = set(active_result.scalars().all()) - matched_flag_ids -= active_ids - - # Subtract dismissed flags - dismissed_result = await db.execute( - select(IngredientFlagDismissal.food_flag_id).where( - IngredientFlagDismissal.ingredient_id == ing_id, - ) - ) - dismissed_ids = set(dismissed_result.scalars().all()) - matched_flag_ids -= dismissed_ids - - if matched_flag_ids: - open_suggestion_ings.append({ - "ingredient_id": ing_id, - "ingredient_name": ing_row.name, - "suggestion_count": len(matched_flag_ids), - }) - - # ── Recipe text keyword scanning ────────────────────────────────── - recipe_text_suggestions = [] - # Load recipe details for text scanning - recipe_result = await db.execute( - select(Recipe.name, Recipe.description, Recipe.notes).where(Recipe.id == recipe_id) - ) - recipe_row = recipe_result.first() - - # Reuse all_keywords if already loaded above, otherwise load now - if not unique_ids or not all_keywords: - kw_result2 = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - all_keywords = kw_result2.scalars().all() - - if recipe_row and all_keywords: - # Collect text sources: (text_value, source_label) - text_sources: list[tuple[str, str]] = [] - if recipe_row.name: - text_sources.append((recipe_row.name, "recipe name")) - if recipe_row.description: - text_sources.append((recipe_row.description, "description")) - if recipe_row.notes: - text_sources.append((recipe_row.notes, "notes")) - - # Also scan ingredient notes - ing_notes_result = await db.execute( - select(RecipeIngredient.notes, Ingredient.name) - .join(Ingredient, Ingredient.id == RecipeIngredient.ingredient_id) - .where( - RecipeIngredient.recipe_id == recipe_id, - RecipeIngredient.notes.isnot(None), - RecipeIngredient.notes != "", - ) - ) - for note_text, ing_name in ing_notes_result.all(): - text_sources.append((note_text, f"ingredient note: {ing_name}")) - - # Match keywords against each text source - text_flag_matches: dict[int, dict] = {} - for src_text, src_label in text_sources: - matches = match_allergen_keywords(src_text, all_keywords) - for m in matches: - fid = m["flag_id"] - if fid not in text_flag_matches: - text_flag_matches[fid] = { - "flag_id": fid, - "flag_name": m["flag_name"], - "flag_code": m.get("flag_code"), - "category_name": m["category_name"], - "matched_keywords": [], - "sources": [], - } - for kw in m["matched_keywords"]: - entry = f"{kw} ({src_label})" - if entry not in text_flag_matches[fid]["matched_keywords"]: - text_flag_matches[fid]["matched_keywords"].append(entry) - if src_label not in text_flag_matches[fid]["sources"]: - text_flag_matches[fid]["sources"].append(src_label) - - if text_flag_matches: - # Subtract flags already in computed recipe flags - computed_flag_ids = set(f.food_flag_id for f in flags) - for fid in list(text_flag_matches.keys()): - if fid in computed_flag_ids: - del text_flag_matches[fid] - - # Subtract dismissed flags - dismissed_result = await db.execute( - select(RecipeTextFlagDismissal.food_flag_id).where( - RecipeTextFlagDismissal.recipe_id == recipe_id, - ) - ) - dismissed_ids = set(dismissed_result.scalars().all()) - for fid in list(text_flag_matches.keys()): - if fid in dismissed_ids: - del text_flag_matches[fid] - - recipe_text_suggestions = list(text_flag_matches.values()) - - # LLM FEATURE — see LLM-MANIFEST.md for removal instructions - # Optionally run LLM analysis on recipe text for contextual allergen detection - llm_recipe_suggestions = [] - try: - from services.llm_service import analyse_product_label - - # Concatenate all text sources for LLM - all_text_parts = [] - if recipe_row: - if recipe_row.name: - all_text_parts.append(f"Recipe: {recipe_row.name}") - if recipe_row.description: - all_text_parts.append(f"Description: {recipe_row.description}") - if recipe_row.notes: - all_text_parts.append(f"Notes: {recipe_row.notes}") - combined_text = "\n".join(all_text_parts) - - if combined_text and len(combined_text.strip()) >= 10: - # Build flag categories for LLM - cat_result_llm = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == user.kitchen_id) - ) - llm_categories = cat_result_llm.scalars().all() - flag_categories_for_llm = [ - { - "category_name": c.name, - "propagation_type": c.propagation_type, - "flags": [{"id": f.id, "name": f.name, "code": f.code} for f in c.flags], - } - for c in llm_categories - ] - - llm_result = await analyse_product_label(db, user.kitchen_id, combined_text, flag_categories_for_llm) - - if llm_result["status"] in ("success", "cached") and llm_result.get("suggestions"): - # Merge LLM suggestions — only add flags not already in keyword results or computed flags - computed_flag_ids = set(f.food_flag_id for f in flags) - keyword_flag_ids = set(s["flag_id"] for s in recipe_text_suggestions) - dismissed_result2 = await db.execute( - select(RecipeTextFlagDismissal.food_flag_id).where( - RecipeTextFlagDismissal.recipe_id == recipe_id, - ) - ) - dismissed_ids2 = set(dismissed_result2.scalars().all()) - - for s in llm_result["suggestions"]: - fid = s["flag_id"] - if fid not in computed_flag_ids and fid not in keyword_flag_ids and fid not in dismissed_ids2: - llm_recipe_suggestions.append(s) - except Exception as e: - logger.warning(f"LLM recipe text scanning failed (non-fatal): {e}") - - return { - "flags": [f.model_dump() for f in flags], - "unassessed_ingredients": unassessed, - "open_suggestion_ingredients": open_suggestion_ings, - "recipe_text_suggestions": recipe_text_suggestions, - "llm_recipe_suggestions": llm_recipe_suggestions, # LLM FEATURE - } - - -@router.post("/recipes/{recipe_id}/flags/{flag_id}/deactivate") -async def deactivate_recipe_flag( - recipe_id: int, - flag_id: int, - data: OverrideRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Override a flag off (requires note, creates audit log).""" - # Upsert recipe_flag - rf_result = await db.execute( - select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id) - ) - rf = rf_result.scalar_one_or_none() - if rf: - rf.is_active = False - else: - rf = RecipeFlag(recipe_id=recipe_id, food_flag_id=flag_id, source_type="auto", is_active=False) - db.add(rf) - - # Audit log - db.add(RecipeFlagOverride( - recipe_id=recipe_id, food_flag_id=flag_id, - action="deactivated", note=data.note, user_id=user.id, - )) - await db.commit() - return {"ok": True} - - -@router.post("/recipes/{recipe_id}/flags/{flag_id}/reactivate") -async def reactivate_recipe_flag( - recipe_id: int, - flag_id: int, - data: OverrideRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - rf_result = await db.execute( - select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id) - ) - rf = rf_result.scalar_one_or_none() - if rf: - rf.is_active = True - - db.add(RecipeFlagOverride( - recipe_id=recipe_id, food_flag_id=flag_id, - action="reactivated", note=data.note, user_id=user.id, - )) - await db.commit() - return {"ok": True} - - -@router.patch("/recipes/{recipe_id}/flags/{flag_id}") -async def toggle_excludable( - recipe_id: int, - flag_id: int, - data: ExcludableToggle, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Toggle excludable_on_request for a dish recipe flag.""" - rf_result = await db.execute( - select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id) - ) - rf = rf_result.scalar_one_or_none() - if rf: - new_state = not rf.excludable_on_request - rf.excludable_on_request = new_state - action = "set_excludable" if new_state else "unset_excludable" - else: - rf = RecipeFlag( - recipe_id=recipe_id, food_flag_id=flag_id, - source_type="auto", is_active=True, excludable_on_request=True, - ) - db.add(rf) - action = "set_excludable" - - db.add(RecipeFlagOverride( - recipe_id=recipe_id, food_flag_id=flag_id, - action=action, note=data.note, user_id=user.id, - )) - await db.commit() - return {"ok": True} - - -@router.post("/recipes/{recipe_id}/flags/manual") -async def add_manual_flag( - recipe_id: int, - data: ManualFlagAdd, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Manually add a flag to a recipe.""" - existing = await db.execute( - select(RecipeFlag).where( - RecipeFlag.recipe_id == recipe_id, - RecipeFlag.food_flag_id == data.food_flag_id, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException(409, "Flag already exists on this recipe") - - db.add(RecipeFlag( - recipe_id=recipe_id, food_flag_id=data.food_flag_id, - source_type="manual", is_active=True, - )) - await db.commit() - return {"ok": True} - - -@router.get("/recipes/{recipe_id}/flags/audit-log") -async def get_flag_audit_log( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(RecipeFlagOverride) - .options( - selectinload(RecipeFlagOverride.food_flag), - selectinload(RecipeFlagOverride.user), - ) - .where(RecipeFlagOverride.recipe_id == recipe_id) - .order_by(RecipeFlagOverride.created_at.desc()) - ) - overrides = result.scalars().all() - return [ - RecipeFlagOverrideLog( - id=o.id, - food_flag_id=o.food_flag_id, - flag_name=o.food_flag.name if o.food_flag else "", - action=o.action, - note=o.note, - username=o.user.username if o.user else "", - created_at=str(o.created_at) if o.created_at else "", - ) - for o in overrides - ] - - -@router.get("/recipes/{recipe_id}/flags/matrix") -async def get_flag_matrix( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Full ingredient × flag matrix data for the flag breakdown table.""" - # Verify recipe - r_result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - recipe = r_result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - - # Get all flag categories and flags - cats = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == user.kitchen_id) - .order_by(FoodFlagCategory.sort_order) - ) - categories = cats.scalars().all() - - all_flags = [] - required_cat_ids = set() - for cat in categories: - if cat.required: - required_cat_ids.add(cat.id) - for f in sorted(cat.flags, key=lambda x: x.sort_order): - all_flags.append({ - "id": f.id, "name": f.name, "code": f.code, - "category_id": cat.id, "category_name": cat.name, - "propagation_type": cat.propagation_type, - "required": cat.required, - }) - - matrix_rows = [] - - # Direct recipe ingredients - ri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags)) - .where(RecipeIngredient.recipe_id == recipe_id) - .order_by(RecipeIngredient.sort_order) - ) - direct_ris = ri_result.scalars().all() - - # Sub-recipe ingredients - sr_result = await db.execute( - select(RecipeSubRecipe) - .options(selectinload(RecipeSubRecipe.child_recipe)) - .where(RecipeSubRecipe.parent_recipe_id == recipe_id) - .order_by(RecipeSubRecipe.sort_order) - ) - sub_recipes = sr_result.scalars().all() - - # Fetch child recipe ingredients for each sub-recipe - sub_recipe_ingredients = {} # child_id -> (child_recipe, [RecipeIngredient...]) - for sr in sub_recipes: - child = sr.child_recipe - if not child: - continue - cri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags)) - .where(RecipeIngredient.recipe_id == child.id) - .order_by(RecipeIngredient.sort_order) - ) - sub_recipe_ingredients[child.id] = (child, cri_result.scalars().all()) - - # Collect all ingredient IDs from direct + sub-recipe ingredients - all_ingredient_ids = set() - for ri in direct_ris: - if ri.ingredient: - all_ingredient_ids.add(ri.ingredient.id) - for child_id, (child, cris) in sub_recipe_ingredients.items(): - for cri in cris: - if cri.ingredient: - all_ingredient_ids.add(cri.ingredient.id) - - # Batch-fetch all "None apply" records so they count as assessed - ingredient_nones = {} - if all_ingredient_ids: - none_result = await db.execute( - select(IngredientFlagNone.ingredient_id, IngredientFlagNone.category_id) - .where(IngredientFlagNone.ingredient_id.in_(list(all_ingredient_ids))) - ) - for ing_id, cat_id in none_result.all(): - if ing_id not in ingredient_nones: - ingredient_nones[ing_id] = set() - ingredient_nones[ing_id].add(cat_id) - - # Compute open suggestion flag IDs per ingredient - ingredient_open_suggestions: dict[int, set[int]] = {} # ing_id -> set of flag_ids with open suggestions - if all_ingredient_ids: - # Load allergen keywords - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - all_keywords = kw_result.scalars().all() - - if all_keywords: - # Batch-fetch dismissed flag IDs per ingredient - ingredient_dismissed: dict[int, set[int]] = {} - dismiss_result = await db.execute( - select(IngredientFlagDismissal.ingredient_id, IngredientFlagDismissal.food_flag_id) - .where(IngredientFlagDismissal.ingredient_id.in_(list(all_ingredient_ids))) - ) - for ing_id, flag_id in dismiss_result.all(): - if ing_id not in ingredient_dismissed: - ingredient_dismissed[ing_id] = set() - ingredient_dismissed[ing_id].add(flag_id) - - # Batch-fetch ingredient names and product_ingredients - ing_data_result = await db.execute( - select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients) - .where(Ingredient.id.in_(list(all_ingredient_ids))) - ) - for ing_id, ing_name, prod_ing in ing_data_result.all(): - texts = [ing_name or ""] - if prod_ing: - texts.append(prod_ing) - combined = " ".join(texts) - matches = match_allergen_keywords(combined, all_keywords) - if matches: - matched_flag_ids = set(m["flag_id"] for m in matches) - # Subtract active flags (from the ingredient's loaded flags) - # We'll do this per-row below since we already have ing_flag_ids there - # For now, subtract dismissed - matched_flag_ids -= ingredient_dismissed.get(ing_id, set()) - if matched_flag_ids: - ingredient_open_suggestions[ing_id] = matched_flag_ids - - # Build matrix rows for direct ingredients - for ri in direct_ris: - ing = ri.ingredient - if not ing: - continue - ing_flag_ids = {f.food_flag_id for f in (ing.flags or [])} - # Check which categories this ingredient has ANY flags in - assessed_cats = set() - for f in (ing.flags or []): - for cat in categories: - if f.food_flag_id in {cf.id for cf in cat.flags}: - assessed_cats.add(cat.id) - # Also count "None apply" categories as assessed - assessed_cats |= ingredient_nones.get(ing.id, set()) - - none_cats = ingredient_nones.get(ing.id, set()) - open_sugg = ingredient_open_suggestions.get(ing.id, set()) - ing_flag_ids - flags_map = {} - for flag_info in all_flags: - fid = flag_info["id"] - cat_id = flag_info["category_id"] - # Non-required categories: never show as unassessed - is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids - flags_map[fid] = MatrixCell( - has_flag=fid in ing_flag_ids, - is_unassessed=is_unassessed, - is_none=cat_id in none_cats, - has_open_suggestion=fid in open_sugg, - ).model_dump() - - matrix_rows.append(MatrixIngredient( - ingredient_id=ing.id, ingredient_name=ing.name, - flags=flags_map, - ).model_dump()) - - # Build matrix rows for sub-recipe ingredients - for sr in sub_recipes: - child = sr.child_recipe - if not child or child.id not in sub_recipe_ingredients: - continue - _, cris = sub_recipe_ingredients[child.id] - for cri in cris: - cing = cri.ingredient - if not cing: - continue - ing_flag_ids = {f.food_flag_id for f in (cing.flags or [])} - assessed_cats = set() - for f in (cing.flags or []): - for cat in categories: - if f.food_flag_id in {cf.id for cf in cat.flags}: - assessed_cats.add(cat.id) - # Also count "None apply" categories as assessed - assessed_cats |= ingredient_nones.get(cing.id, set()) - - none_cats = ingredient_nones.get(cing.id, set()) - open_sugg = ingredient_open_suggestions.get(cing.id, set()) - ing_flag_ids - flags_map = {} - for flag_info in all_flags: - fid = flag_info["id"] - cat_id = flag_info["category_id"] - is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids - flags_map[fid] = MatrixCell( - has_flag=fid in ing_flag_ids, - is_unassessed=is_unassessed, - is_none=cat_id in none_cats, - has_open_suggestion=fid in open_sugg, - ).model_dump() - - matrix_rows.append(MatrixIngredient( - ingredient_id=cing.id, ingredient_name=cing.name, - is_sub_recipe=True, sub_recipe_name=child.name, - flags=flags_map, - ).model_dump()) - - return {"flags": all_flags, "ingredients": matrix_rows} - - -class MatrixBulkItem(BaseModel): - ingredient_id: int - food_flag_id: int - has_flag: bool - - -class MatrixBulkUpdate(BaseModel): - updates: list[MatrixBulkItem] - - -@router.put("/recipes/{recipe_id}/flags/matrix") -async def update_flag_matrix( - recipe_id: int, - data: MatrixBulkUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Bulk update ingredient flags from the recipe flag matrix view.""" - # Verify recipe belongs to kitchen - r_result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r_result.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - updated = 0 - for item in data.updates: - # Verify ingredient belongs to this kitchen - ing_result = await db.execute( - select(Ingredient).where( - Ingredient.id == item.ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - ingredient = ing_result.scalar_one_or_none() - if not ingredient: - continue - - # Verify flag belongs to this kitchen - flag_result = await db.execute( - select(FoodFlag).where( - FoodFlag.id == item.food_flag_id, - FoodFlag.kitchen_id == user.kitchen_id, - ) - ) - if not flag_result.scalar_one_or_none(): - continue - - # Check if flag assignment exists - existing = await db.execute( - select(IngredientFlag).where( - IngredientFlag.ingredient_id == item.ingredient_id, - IngredientFlag.food_flag_id == item.food_flag_id, - ) - ) - flag_row = existing.scalar_one_or_none() - - if item.has_flag and not flag_row: - # Add flag — also remove any "None apply" for this flag's category - flag_obj = flag_result.scalar_one_or_none() if not flag_result else None - # Re-fetch to get category_id - flag_detail = await db.execute( - select(FoodFlag).where(FoodFlag.id == item.food_flag_id) - ) - flag_detail_obj = flag_detail.scalar_one_or_none() - if flag_detail_obj: - await db.execute( - delete(IngredientFlagNone).where( - IngredientFlagNone.ingredient_id == item.ingredient_id, - IngredientFlagNone.category_id == flag_detail_obj.category_id, - ) - ) - db.add(IngredientFlag( - ingredient_id=item.ingredient_id, - food_flag_id=item.food_flag_id, - flagged_by=user.id, - source="manual", - )) - updated += 1 - elif not item.has_flag and flag_row: - # Remove flag (only manual ones; latched flags stay) - if flag_row.source == "manual": - await db.delete(flag_row) - updated += 1 - - if updated > 0: - # Bump recipe updated_at so menu staleness detection picks up flag changes - r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) - recipe_obj = r2.scalar_one_or_none() - if recipe_obj: - recipe_obj.updated_at = datetime.utcnow() - await db.commit() - return {"ok": True, "updated": updated} - - -class MatrixNoneToggle(BaseModel): - ingredient_id: int - category_id: int - - -@router.post("/recipes/{recipe_id}/flags/matrix/none") -async def toggle_matrix_none( - recipe_id: int, - data: MatrixNoneToggle, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Toggle 'None apply' for a category on an ingredient, from the recipe matrix.""" - # Verify recipe belongs to kitchen - r_result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r_result.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - # Check if already set - existing = await db.execute( - select(IngredientFlagNone).where( - IngredientFlagNone.ingredient_id == data.ingredient_id, - IngredientFlagNone.category_id == data.category_id, - ) - ) - none_row = existing.scalar_one_or_none() - - # Helper to bump recipe updated_at for staleness detection - async def _bump_recipe(): - r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) - robj = r2.scalar_one_or_none() - if robj: - robj.updated_at = datetime.utcnow() - - if none_row: - # Toggle OFF - await db.delete(none_row) - await _bump_recipe() - await db.commit() - return {"ok": True, "is_none": False} - else: - # Toggle ON — remove any flags in this category first - cat_flag_ids = await db.execute( - select(FoodFlag.id).where( - FoodFlag.category_id == data.category_id, - FoodFlag.kitchen_id == user.kitchen_id, - ) - ) - flag_ids = [r for r in cat_flag_ids.scalars().all()] - if flag_ids: - await db.execute( - delete(IngredientFlag).where( - IngredientFlag.ingredient_id == data.ingredient_id, - IngredientFlag.food_flag_id.in_(flag_ids), - ) - ) - db.add(IngredientFlagNone( - ingredient_id=data.ingredient_id, - category_id=data.category_id, - )) - await _bump_recipe() - await db.commit() - return {"ok": True, "is_none": True} - - -# ── Recipe text flag dismissals ────────────────────────────────────────────── - -class RecipeTextDismissalCreate(BaseModel): - food_flag_id: int - dismissed_by_name: str - reason: Optional[str] = None - matched_keyword: Optional[str] = None - -class RecipeTextDismissalResponse(BaseModel): - id: int - recipe_id: int - food_flag_id: int - dismissed_by_name: str - reason: Optional[str] = None - matched_keyword: Optional[str] = None - created_at: str = "" - class Config: - from_attributes = True - - -@router.get("/recipes/{recipe_id}/text-dismissals") -async def get_recipe_text_dismissals( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get all dismissed recipe text allergen suggestions.""" - r = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - result = await db.execute( - select(RecipeTextFlagDismissal) - .where(RecipeTextFlagDismissal.recipe_id == recipe_id) - .order_by(RecipeTextFlagDismissal.created_at.desc()) - ) - dismissals = result.scalars().all() - return [ - RecipeTextDismissalResponse( - id=d.id, - recipe_id=d.recipe_id, - food_flag_id=d.food_flag_id, - dismissed_by_name=d.dismissed_by_name, - reason=d.reason, - matched_keyword=d.matched_keyword, - created_at=str(d.created_at) if d.created_at else "", - ) - for d in dismissals - ] - - -@router.post("/recipes/{recipe_id}/text-dismissals") -async def create_recipe_text_dismissal( - recipe_id: int, - data: RecipeTextDismissalCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Dismiss a recipe text allergen suggestion (upsert).""" - r = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - existing = await db.execute( - select(RecipeTextFlagDismissal).where( - RecipeTextFlagDismissal.recipe_id == recipe_id, - RecipeTextFlagDismissal.food_flag_id == data.food_flag_id, - ) - ) - dismissal = existing.scalar_one_or_none() - if dismissal: - dismissal.dismissed_by_name = data.dismissed_by_name - dismissal.reason = data.reason - dismissal.matched_keyword = data.matched_keyword - else: - dismissal = RecipeTextFlagDismissal( - recipe_id=recipe_id, - food_flag_id=data.food_flag_id, - dismissed_by_name=data.dismissed_by_name, - reason=data.reason, - matched_keyword=data.matched_keyword, - ) - db.add(dismissal) - - await db.commit() - await db.refresh(dismissal) - return RecipeTextDismissalResponse( - id=dismissal.id, - recipe_id=dismissal.recipe_id, - food_flag_id=dismissal.food_flag_id, - dismissed_by_name=dismissal.dismissed_by_name, - reason=dismissal.reason, - matched_keyword=dismissal.matched_keyword, - created_at=str(dismissal.created_at) if dismissal.created_at else "", - ) - - -@router.delete("/recipes/{recipe_id}/text-dismissals/{dismissal_id}") -async def delete_recipe_text_dismissal( - recipe_id: int, - dismissal_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Undo a recipe text dismissal (re-enables the suggestion).""" - r = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - if not r.scalar_one_or_none(): - raise HTTPException(404, "Recipe not found") - - result = await db.execute( - select(RecipeTextFlagDismissal).where( - RecipeTextFlagDismissal.id == dismissal_id, - RecipeTextFlagDismissal.recipe_id == recipe_id, - ) - ) - dismissal = result.scalar_one_or_none() - if not dismissal: - raise HTTPException(404, "Dismissal not found") - - await db.delete(dismissal) - await db.commit() - return {"ok": True} - - -# ── Shared allergen keyword matching ───────────────────────────────────────── - -def match_allergen_keywords(text: str, keywords: list) -> list[dict]: - """Match text against allergen keywords using word boundary matching. - Allows optional plural suffixes (s, es, 's) so 'almond' matches 'almonds' etc.""" - text_lower = text.lower() - matches: dict[int, dict] = {} - for kw in keywords: - # Word boundary + optional plural/possessive suffix to catch almonds, walnuts, etc. - pattern = r'\b' + re.escape(kw.keyword) + r"(?:'?e?s)?\b" - if re.search(pattern, text_lower): - fid = kw.food_flag_id - if fid not in matches: - flag = kw.food_flag - matches[fid] = { - "flag_id": fid, - "flag_name": flag.name if flag else "", - "flag_code": flag.code if flag else None, - "category_name": flag.category.name if flag and flag.category else "", - "matched_keywords": [], - } - matches[fid]["matched_keywords"].append(kw.keyword) - return list(matches.values()) - - -# ── Allergen keyword suggestion ────────────────────────────────────────────── - -@router.get("/suggest") -async def suggest_allergens( - name: str = Query("", description="Ingredient name to check"), - text: str = Query("", description="Product ingredients text to check"), - line_item: str = Query("", description="Line item description to check"), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Suggest allergen flags based on ingredient name, line item description, and/or product ingredients text.""" - if not any(len(s.strip()) >= 2 for s in [name, text, line_item]): - return [] - - result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - keywords = result.scalars().all() - - # Match each source separately and annotate keywords with their origin - sources = [ - (name, "name"), - (line_item, "line item"), - (text, "ingredients"), - ] - merged: dict[int, dict] = {} - for input_text, source_label in sources: - if not input_text or len(input_text.strip()) < 2: - continue - matches = match_allergen_keywords(input_text, keywords) - for m in matches: - fid = m["flag_id"] - if fid not in merged: - merged[fid] = { - "flag_id": m["flag_id"], - "flag_name": m["flag_name"], - "flag_code": m["flag_code"], - "category_name": m["category_name"], - "matched_keywords": [], - } - for kw in m["matched_keywords"]: - merged[fid]["matched_keywords"].append(f"{kw} ({source_label})") - - return list(merged.values()) - - -@router.get("/suggest/bulk") -async def suggest_allergens_bulk( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Return allergen keyword suggestions for ALL ingredients in the kitchen. - Single DB query for keywords, then match against each ingredient's name + product_ingredients. - Returns: { ingredient_id: [ { flag_id, flag_name, flag_code, category_name, matched_keywords } ] } - """ - # Load all keywords once - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - keywords = kw_result.scalars().all() - if not keywords: - return {} - - # Load all non-archived ingredients - ing_result = await db.execute( - select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients) - .where(Ingredient.kitchen_id == user.kitchen_id, Ingredient.is_archived == False) - ) - ingredients = ing_result.all() - - result: dict[int, list[dict]] = {} - for ing_id, ing_name, product_ingredients in ingredients: - merged: dict[int, dict] = {} - for input_text, source_label in [(ing_name, "name"), (product_ingredients, "ingredients")]: - if not input_text or len(input_text.strip()) < 2: - continue - matches = match_allergen_keywords(input_text, keywords) - for m in matches: - fid = m["flag_id"] - if fid not in merged: - merged[fid] = { - "flag_id": m["flag_id"], - "flag_name": m["flag_name"], - "flag_code": m["flag_code"], - "category_name": m["category_name"], - "matched_keywords": [], - } - for kw in m["matched_keywords"]: - merged[fid]["matched_keywords"].append(f"{kw} ({source_label})") - if merged: - result[ing_id] = list(merged.values()) - - return result - - -# ── LLM Label Analysis ────────────────────────────────────────────────────── -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions - - -class AnalyseLabelRequest(BaseModel): - ingredients_text: str | None = None - ingredient_id: int | None = None - - -@router.post("/analyse-label") -async def analyse_label( - body: AnalyseLabelRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Analyse product ingredient text or recipe text for allergens using LLM. - Returns suggestions in the same format as keyword matching, with added status and source fields. - Falls back to keyword matching if LLM is unavailable. - """ - # Get the text to analyse - text = body.ingredients_text - if not text and body.ingredient_id: - result = await db.execute( - select(Ingredient).where( - Ingredient.id == body.ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - ingredient = result.scalar_one_or_none() - if ingredient and ingredient.product_ingredients: - text = ingredient.product_ingredients - - if not text or len(text.strip()) < 3: - return {"llm_status": "unavailable", "suggestions": [], "keyword_suggestions": []} - - # Always run keyword matching as baseline - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - keywords = kw_result.scalars().all() - keyword_suggestions = match_allergen_keywords(text, keywords) - - # Build flag categories for LLM - cat_result = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == user.kitchen_id) - ) - categories = cat_result.scalars().all() - - flag_categories = [] - for cat in categories: - flag_categories.append({ - "category_name": cat.name, - "propagation_type": cat.propagation_type, - "flags": [{"id": f.id, "name": f.name, "code": f.code} for f in cat.flags], - }) - - # Call LLM analysis - from services.llm_service import analyse_product_label - llm_result = await analyse_product_label(db, user.kitchen_id, text, flag_categories) - - return { - "llm_status": llm_result["status"], - "suggestions": llm_result.get("suggestions") or [], - "keyword_suggestions": keyword_suggestions, - "error": llm_result.get("error"), - } - - -# ── Allergen keyword CRUD (Settings) ──────────────────────────────────────── - -class KeywordCreate(BaseModel): - food_flag_id: int - keyword: str - -@router.get("/keywords") -async def list_keywords( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Return all allergen keywords grouped by flag.""" - result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - .order_by(AllergenKeyword.food_flag_id, AllergenKeyword.keyword) - ) - keywords = result.scalars().all() - - groups: dict[int, dict] = {} - for kw in keywords: - fid = kw.food_flag_id - if fid not in groups: - flag = kw.food_flag - groups[fid] = { - "flag_id": fid, - "flag_name": flag.name if flag else "", - "flag_code": flag.code if flag else None, - "category_name": flag.category.name if flag and flag.category else "", - "keywords": [], - } - groups[fid]["keywords"].append({ - "id": kw.id, - "keyword": kw.keyword, - "is_default": kw.is_default, - }) - return list(groups.values()) - - -@router.post("/keywords") -async def add_keyword( - data: KeywordCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Add a custom allergen keyword.""" - # Verify flag belongs to this kitchen - flag = await db.get(FoodFlag, data.food_flag_id) - if not flag or flag.kitchen_id != user.kitchen_id: - raise HTTPException(404, "Flag not found") - - kw = AllergenKeyword( - kitchen_id=user.kitchen_id, - food_flag_id=data.food_flag_id, - keyword=data.keyword.lower().strip(), - is_default=False, - ) - db.add(kw) - try: - await db.commit() - await db.refresh(kw) - except Exception: - await db.rollback() - raise HTTPException(409, "Keyword already exists for this flag") - return {"id": kw.id, "keyword": kw.keyword, "is_default": False} - - -@router.delete("/keywords/{keyword_id}") -async def delete_keyword( - keyword_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Remove an allergen keyword.""" - kw = await db.get(AllergenKeyword, keyword_id) - if not kw or kw.kitchen_id != user.kitchen_id: - raise HTTPException(404, "Keyword not found") - await db.delete(kw) - await db.commit() - return {"ok": True} - - -@router.post("/keywords/reset-defaults") -async def reset_default_keywords( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Delete all default keywords and re-seed from built-in dictionary. Preserves user-added keywords.""" - from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS - from sqlalchemy import text as sql_text - - # Delete existing defaults - await db.execute( - delete(AllergenKeyword).where( - AllergenKeyword.kitchen_id == user.kitchen_id, - AllergenKeyword.is_default == True, - ) - ) - await db.flush() - - # Re-seed defaults - seeded = 0 - for flag_name, keywords in ALLERGEN_KEYWORDS.items(): - flag_result = await db.execute( - select(FoodFlag).where( - FoodFlag.kitchen_id == user.kitchen_id, - FoodFlag.name == flag_name, - ) - ) - flag = flag_result.scalar_one_or_none() - if not flag: - continue - for kw_str in keywords: - kw = AllergenKeyword( - kitchen_id=user.kitchen_id, - food_flag_id=flag.id, - keyword=kw_str.lower(), - is_default=True, - ) - db.add(kw) - seeded += 1 - - try: - await db.commit() - except Exception: - await db.rollback() - raise HTTPException(500, "Failed to re-seed keywords") - - return {"ok": True, "seeded": seeded} - - -# ── Label OCR scanning ────────────────────────────────────────────────────── - -@router.post("/scan-label") -async def scan_label( - file: UploadFile = File(...), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """OCR a product ingredient label image and suggest allergen flags. - For create mode (ingredient doesn't exist yet) — returns raw text + suggestions. - """ - # Validate file type - allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"} - if file.content_type not in allowed: - raise HTTPException(400, f"Unsupported file type: {file.content_type}") - - # Get Azure credentials - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - if not settings or not settings.azure_endpoint or not settings.azure_key: - raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.") - - # Read file content - image_bytes = await file.read() - - # OCR with Azure prebuilt-read - try: - from azure.ai.formrecognizer import DocumentAnalysisClient - from azure.core.credentials import AzureKeyCredential - - client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key)) - poller = client.begin_analyze_document("prebuilt-read", document=image_bytes) - result = poller.result() - raw_text = " ".join([line.content for page in result.pages for line in page.lines]) - except Exception as e: - logger.error(f"Azure OCR failed: {e}") - raise HTTPException(500, f"OCR failed: {str(e)}") - - # Match keywords - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - keywords = kw_result.scalars().all() - suggestions = match_allergen_keywords(raw_text, keywords) - - return { - "raw_text": raw_text, - "suggested_flags": suggestions, - } - - -@router.post("/scan-label/{ingredient_id}") -async def scan_label_for_ingredient( - ingredient_id: int, - file: UploadFile = File(...), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """OCR a product ingredient label, save the image, and suggest allergen flags. - For edit mode (ingredient exists) — saves label image to disk. - """ - # Verify ingredient - ing = await db.get(Ingredient, ingredient_id) - if not ing or ing.kitchen_id != user.kitchen_id: - raise HTTPException(404, "Ingredient not found") - - # Validate file type - allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"} - if file.content_type not in allowed: - raise HTTPException(400, f"Unsupported file type: {file.content_type}") - - # Get Azure credentials - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - if not settings or not settings.azure_endpoint or not settings.azure_key: - raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.") - - # Read file content - image_bytes = await file.read() - - # Save label image - ext = file.filename.rsplit(".", 1)[-1] if file.filename and "." in file.filename else "jpg" - label_dir = f"/app/data/{user.kitchen_id}/labels" - os.makedirs(label_dir, exist_ok=True) - filename = f"{uuid.uuid4()}.{ext}" - filepath = os.path.join(label_dir, filename) - async with aiofiles.open(filepath, "wb") as f: - await f.write(image_bytes) - - # Update ingredient - ing.label_image_path = filepath - await db.flush() - - # OCR with Azure prebuilt-read - try: - from azure.ai.formrecognizer import DocumentAnalysisClient - from azure.core.credentials import AzureKeyCredential - - client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key)) - poller = client.begin_analyze_document("prebuilt-read", document=image_bytes) - result = poller.result() - raw_text = " ".join([line.content for page in result.pages for line in page.lines]) - except Exception as e: - logger.error(f"Azure OCR failed: {e}") - await db.commit() # Still save the image even if OCR fails - raise HTTPException(500, f"OCR failed: {str(e)}") - - # Update product_ingredients - ing.product_ingredients = raw_text - await db.commit() - - # Match keywords - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == user.kitchen_id) - ) - keywords = kw_result.scalars().all() - suggestions = match_allergen_keywords(raw_text, keywords) - - return { - "raw_text": raw_text, - "suggested_flags": suggestions, - "label_saved": True, - } - - -# ── Brakes product lookup ───────────────────────────────────────────────── - -@router.get("/brakes-lookup") -async def brakes_lookup( - product_code: str = Query(..., description="Brakes product code"), - force: bool = Query(False, description="Bypass cache and re-fetch from website"), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Look up a Brakes product by code, returning ingredients + allergen suggestions. - Uses a cache table to avoid repeated requests to brake.co.uk. - """ - import json - from datetime import datetime, timedelta - from services.brakes_scraper import fetch_brakes_product - - clean_code = product_code.lstrip("$").strip() - if not clean_code: - raise HTTPException(400, "Product code required") - - # Check cache - cache_result = await db.execute( - select(BrakesProductCache).where(BrakesProductCache.product_code == clean_code) - ) - cached = cache_result.scalar_one_or_none() - - now = datetime.utcnow() - cache_ttl = timedelta(days=30) - not_found_ttl = timedelta(days=7) - - if cached and not force: - age = now - cached.fetched_at - if cached.not_found and age < not_found_ttl: - return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []} - if not cached.not_found and age < cache_ttl: - # Serve from cache — build suggestions - contains = json.loads(cached.contains_allergens) if cached.contains_allergens else [] - dietary = json.loads(cached.dietary_info) if cached.dietary_info else [] - suggestions, none_cat_ids = await _build_brakes_suggestions( - db, user.kitchen_id, contains, cached.ingredients_text or "", dietary - ) - return { - "found": True, - "product_code": clean_code, - "product_name": cached.product_name or "", - "ingredients_text": cached.ingredients_text or "", - "contains_allergens": contains, - "suitable_for": dietary, - "suggested_flags": suggestions, - "none_category_ids": none_cat_ids, - } - - # Cache miss or stale — fetch from website - product = await fetch_brakes_product(clean_code) - - if product is None or (not product.ingredients_text and not product.product_name): - # 404 or empty page — cache as not_found - if cached: - cached.not_found = True - cached.fetched_at = now - else: - db.add(BrakesProductCache( - product_code=clean_code, - not_found=True, - fetched_at=now, - )) - await db.commit() - return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []} - - # Store in cache - contains_json = json.dumps(product.contains_allergens) - dietary_json = json.dumps(product.suitable_for) - if cached: - cached.product_name = product.product_name - cached.ingredients_text = product.ingredients_text - cached.contains_allergens = contains_json - cached.dietary_info = dietary_json - cached.not_found = False - cached.fetched_at = now - else: - db.add(BrakesProductCache( - product_code=clean_code, - product_name=product.product_name, - ingredients_text=product.ingredients_text, - contains_allergens=contains_json, - dietary_info=dietary_json, - not_found=False, - fetched_at=now, - )) - await db.commit() - - # Build suggestions - suggestions, none_cat_ids = await _build_brakes_suggestions( - db, user.kitchen_id, product.contains_allergens, product.ingredients_text, product.suitable_for - ) - - return { - "found": True, - "product_code": clean_code, - "product_name": product.product_name, - "ingredients_text": product.ingredients_text, - "contains_allergens": product.contains_allergens, - "suitable_for": product.suitable_for, - "suggested_flags": suggestions, - "none_category_ids": none_cat_ids, - } - - -async def _build_brakes_suggestions( - db: AsyncSession, - kitchen_id: int, - contains_allergens: list[str], - ingredients_text: str, - suitable_for: list[str] | None = None, -) -> list[dict]: - """Build allergen flag suggestions from Brakes 'Contains' statement + keyword matching + dietary suitability.""" - # Get all flags for this kitchen - flags_result = await db.execute( - select(FoodFlag) - .options(selectinload(FoodFlag.category)) - .where(FoodFlag.kitchen_id == kitchen_id) - ) - all_flags = flags_result.scalars().all() - flag_by_name = {f.name.lower(): f for f in all_flags} - - suggestions: dict[int, dict] = {} - - def _find_flag(name: str): - """Match flag by exact name, then try singular/plural variants.""" - n = name.lower().strip() - if n in flag_by_name: - return flag_by_name[n] - # Try removing trailing 's' (Eggs -> Egg, Crustaceans -> Crustacean) - if n.endswith("s") and n[:-1] in flag_by_name: - return flag_by_name[n[:-1]] - # Try adding 's' (Egg -> Eggs, Peanut -> Peanuts) - if f"{n}s" in flag_by_name: - return flag_by_name[f"{n}s"] - # Try 'es' removal (Mollusces -> Mollusc) - if n.endswith("es") and n[:-2] in flag_by_name: - return flag_by_name[n[:-2]] - return None - - # 1. Direct match from "Contains" statement (high confidence) - for allergen_name in contains_allergens: - flag = _find_flag(allergen_name) - if flag: - suggestions[flag.id] = { - "flag_id": flag.id, - "flag_name": flag.name, - "flag_code": flag.code, - "category_name": flag.category.name if flag.category else "", - "source": "contains", - "matched_keywords": [], - } - - # 1b. Dietary suitability — "Suitable for Vegetarians" / "Suitable for Vegans" - if suitable_for: - for diet in suitable_for: - flag = _find_flag(diet) - if flag and flag.id not in suggestions: - suggestions[flag.id] = { - "flag_id": flag.id, - "flag_name": flag.name, - "flag_code": flag.code, - "category_name": flag.category.name if flag.category else "", - "source": "dietary", - "matched_keywords": [], - } - - # 2. Keyword matching against full ingredients text (catches extras) - if ingredients_text: - kw_result = await db.execute( - select(AllergenKeyword) - .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category)) - .where(AllergenKeyword.kitchen_id == kitchen_id) - ) - keywords = kw_result.scalars().all() - keyword_matches = match_allergen_keywords(ingredients_text, keywords) - - for km in keyword_matches: - fid = km["flag_id"] - if fid not in suggestions: - km["source"] = "keyword" - suggestions[fid] = km - else: - # Already matched via "contains" — append keyword info - suggestions[fid]["matched_keywords"] = km.get("matched_keywords", []) - - # 3. Determine none_category_ids: when Brakes says "Contains: None of the 14 Food Allergens" - # (empty contains_allergens but non-empty ingredients_text = product found with no allergens) - none_category_ids = [] - if not contains_allergens and ingredients_text: - cat_result = await db.execute( - select(FoodFlagCategory.id).where( - FoodFlagCategory.kitchen_id == kitchen_id, - FoodFlagCategory.propagation_type == "contains", - ) - ) - none_category_ids = [r for r in cat_result.scalars().all()] - - return list(suggestions.values()), none_category_ids diff --git a/backend/api/imap.py b/backend/api/imap.py deleted file mode 100644 index b2863bb..0000000 --- a/backend/api/imap.py +++ /dev/null @@ -1,341 +0,0 @@ -""" -IMAP Email Inbox API endpoints for settings and sync control. -""" -import logging -from decimal import Decimal -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy import select, desc -from sqlalchemy.ext.asyncio import AsyncSession - -from auth import get_current_user, require_cap -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from models.email_processing import EmailProcessingLog -from services.imap_sync import ImapSyncService - -router = APIRouter(prefix="/imap", tags=["IMAP"]) -logger = logging.getLogger(__name__) - - -# ============ Pydantic Schemas ============ - -class ImapSettingsResponse(BaseModel): - imap_host: Optional[str] - imap_port: Optional[int] - imap_use_ssl: bool - imap_username: Optional[str] - imap_password_set: bool # Don't expose actual password - imap_folder: Optional[str] - imap_poll_interval: int - imap_enabled: bool - imap_confidence_threshold: Optional[float] - imap_last_sync: Optional[str] - - -class ImapSettingsUpdate(BaseModel): - imap_host: Optional[str] = None - imap_port: Optional[int] = None - imap_use_ssl: Optional[bool] = None - imap_username: Optional[str] = None - imap_password: Optional[str] = None # Only set if provided - imap_folder: Optional[str] = None - imap_poll_interval: Optional[int] = None - imap_enabled: Optional[bool] = None - imap_confidence_threshold: Optional[float] = None - - -class ImapTestRequest(BaseModel): - imap_host: Optional[str] = None - imap_port: Optional[int] = None - imap_use_ssl: Optional[bool] = None - imap_username: Optional[str] = None - imap_password: Optional[str] = None - - -class EmailLogResponse(BaseModel): - id: int - message_id: str - email_subject: Optional[str] - email_from: Optional[str] - email_date: Optional[str] - attachments_count: int - invoices_created: int - confident_invoices: int - marked_as_read: bool - processing_status: str - error_message: Optional[str] - invoice_ids: Optional[list[int]] - processed_at: str - - -# ============ Settings Endpoints ============ - -@router.get("/settings", response_model=ImapSettingsResponse) -async def get_imap_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get IMAP settings (password masked)""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - return ImapSettingsResponse( - imap_host=settings.imap_host, - imap_port=settings.imap_port, - imap_use_ssl=settings.imap_use_ssl, - imap_username=settings.imap_username, - imap_password_set=bool(settings.imap_password), - imap_folder=settings.imap_folder, - imap_poll_interval=settings.imap_poll_interval, - imap_enabled=settings.imap_enabled, - imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None, - imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None - ) - - -@router.patch("/settings", response_model=ImapSettingsResponse) -async def update_imap_settings( - update: ImapSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update IMAP settings (admin only)""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Update only provided fields - if update.imap_host is not None: - settings.imap_host = update.imap_host - if update.imap_port is not None: - settings.imap_port = update.imap_port - if update.imap_use_ssl is not None: - settings.imap_use_ssl = update.imap_use_ssl - if update.imap_username is not None: - settings.imap_username = update.imap_username - if update.imap_password is not None and update.imap_password: - settings.imap_password = update.imap_password - if update.imap_folder is not None: - settings.imap_folder = update.imap_folder - if update.imap_poll_interval is not None: - settings.imap_poll_interval = update.imap_poll_interval - if update.imap_enabled is not None: - settings.imap_enabled = update.imap_enabled - if update.imap_confidence_threshold is not None: - settings.imap_confidence_threshold = Decimal(str(update.imap_confidence_threshold)) - - await db.commit() - - return ImapSettingsResponse( - imap_host=settings.imap_host, - imap_port=settings.imap_port, - imap_use_ssl=settings.imap_use_ssl, - imap_username=settings.imap_username, - imap_password_set=bool(settings.imap_password), - imap_folder=settings.imap_folder, - imap_poll_interval=settings.imap_poll_interval, - imap_enabled=settings.imap_enabled, - imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None, - imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None - ) - - -@router.post("/test-connection") -async def test_imap_connection( - request: ImapTestRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test IMAP connection with provided or saved settings""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - # Get current settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Use provided values or fall back to saved settings - test_settings = KitchenSettings( - kitchen_id=current_user.kitchen_id, - imap_host=request.imap_host or settings.imap_host, - imap_port=request.imap_port or settings.imap_port or 993, - imap_use_ssl=request.imap_use_ssl if request.imap_use_ssl is not None else settings.imap_use_ssl, - imap_username=request.imap_username or settings.imap_username, - imap_password=request.imap_password or settings.imap_password - ) - - if not test_settings.imap_host or not test_settings.imap_password: - return {"success": False, "error": "IMAP host and password are required"} - - # Test connection - sync_service = ImapSyncService(current_user.kitchen_id, db) - sync_service._settings = test_settings - result = await sync_service.test_connection() - - return result - - -@router.post("/sync-now") -async def trigger_manual_sync( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Manually trigger inbox sync (admin only)""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - # Get settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not settings.imap_host or not settings.imap_password: - raise HTTPException(status_code=400, detail="IMAP settings not configured") - - # Run sync - try: - sync_service = ImapSyncService(current_user.kitchen_id, db) - results = await sync_service.process_inbox() - return { - "success": True, - "results": results - } - except Exception as e: - logger.error(f"Manual IMAP sync failed: {e}") - return { - "success": False, - "error": str(e) - } - - -# ============ Log Endpoints ============ - -@router.get("/logs", response_model=list[EmailLogResponse]) -async def get_processing_logs( - limit: int = 50, - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get recent email processing logs""" - result = await db.execute( - select(EmailProcessingLog) - .where(EmailProcessingLog.kitchen_id == current_user.kitchen_id) - .order_by(desc(EmailProcessingLog.processed_at)) - .offset(offset) - .limit(limit) - ) - logs = result.scalars().all() - - return [ - EmailLogResponse( - id=log.id, - message_id=log.message_id, - email_subject=log.email_subject, - email_from=log.email_from, - email_date=log.email_date.isoformat() if log.email_date else None, - attachments_count=log.attachments_count, - invoices_created=log.invoices_created, - confident_invoices=log.confident_invoices, - marked_as_read=log.marked_as_read, - processing_status=log.processing_status, - error_message=log.error_message, - invoice_ids=log.invoice_ids, - processed_at=log.processed_at.isoformat() - ) - for log in logs - ] - - -@router.get("/logs/stats") -async def get_sync_stats( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get sync statistics""" - from sqlalchemy import func - from datetime import datetime, timedelta - - now = datetime.utcnow() - - # Last 24 hours - result_24h = await db.execute( - select( - func.count(EmailProcessingLog.id), - func.sum(EmailProcessingLog.invoices_created), - func.sum(EmailProcessingLog.confident_invoices) - ).where( - EmailProcessingLog.kitchen_id == current_user.kitchen_id, - EmailProcessingLog.processed_at >= now - timedelta(hours=24) - ) - ) - stats_24h = result_24h.one() - - # Last 7 days - result_7d = await db.execute( - select( - func.count(EmailProcessingLog.id), - func.sum(EmailProcessingLog.invoices_created), - func.sum(EmailProcessingLog.confident_invoices) - ).where( - EmailProcessingLog.kitchen_id == current_user.kitchen_id, - EmailProcessingLog.processed_at >= now - timedelta(days=7) - ) - ) - stats_7d = result_7d.one() - - # Last 30 days - result_30d = await db.execute( - select( - func.count(EmailProcessingLog.id), - func.sum(EmailProcessingLog.invoices_created), - func.sum(EmailProcessingLog.confident_invoices) - ).where( - EmailProcessingLog.kitchen_id == current_user.kitchen_id, - EmailProcessingLog.processed_at >= now - timedelta(days=30) - ) - ) - stats_30d = result_30d.one() - - return { - "last_24h": { - "emails_processed": stats_24h[0] or 0, - "invoices_created": int(stats_24h[1] or 0), - "confident_invoices": int(stats_24h[2] or 0) - }, - "last_7d": { - "emails_processed": stats_7d[0] or 0, - "invoices_created": int(stats_7d[1] or 0), - "confident_invoices": int(stats_7d[2] or 0) - }, - "last_30d": { - "emails_processed": stats_30d[0] or 0, - "invoices_created": int(stats_30d[1] or 0), - "confident_invoices": int(stats_30d[2] or 0) - } - } diff --git a/backend/api/ingredients.py b/backend/api/ingredients.py deleted file mode 100644 index 2982884..0000000 --- a/backend/api/ingredients.py +++ /dev/null @@ -1,2013 +0,0 @@ -""" -Ingredient Library API — categories, ingredients, sources, auto-price, duplicate detection. -""" -import logging -from datetime import date, datetime -from decimal import Decimal -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi.responses import FileResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, text, and_, or_, delete, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import selectinload -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.ingredient import Ingredient, IngredientCategory, IngredientSource, IngredientFlag, IngredientFlagNone, IngredientFlagDismissal -from models.food_flag import FoodFlag, FoodFlagCategory -from models.line_item import LineItem -from models.invoice import Invoice -from models.supplier import Supplier -from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe -from auth import get_current_user, require_cap - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def _bump_recipes_using_ingredient(ingredient_id: int, db: AsyncSession): - """Bump updated_at on all recipes (and their parents) that use this ingredient, - so menu staleness detection picks up allergen flag changes.""" - ri_result = await db.execute( - select(RecipeIngredient.recipe_id).where(RecipeIngredient.ingredient_id == ingredient_id) - ) - recipe_ids = set(r[0] for r in ri_result.fetchall()) - # Also parent recipes (1 level up) - for rid in list(recipe_ids): - parent_result = await db.execute( - select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == rid) - ) - for (parent_id,) in parent_result.fetchall(): - recipe_ids.add(parent_id) - if recipe_ids: - now = datetime.utcnow() - for rid in recipe_ids: - r = await db.execute(select(Recipe).where(Recipe.id == rid)) - recipe = r.scalar_one_or_none() - if recipe: - recipe.updated_at = now - await db.commit() - - -# ── Unit conversion constants ──────────────────────────────────────────────── - -UNIT_CONVERSIONS = { - "g": {"g": 1, "kg": 0.001}, - "kg": {"g": 1000, "kg": 1}, - "oz": {"g": 28.3495, "kg": 0.0283495}, - "lb": {"g": 453.592, "kg": 0.453592}, - "ml": {"ml": 1, "ltr": 0.001}, - "cl": {"ml": 10, "ltr": 0.01}, - "ltr": {"ml": 1000, "ltr": 1}, - "each": {"each": 1}, -} - - -def convert_to_standard(value: Decimal, from_unit: str, standard_unit: str) -> Optional[Decimal]: - """Convert a value from from_unit to standard_unit. Returns None if incompatible.""" - from_unit = from_unit.lower().strip() - standard_unit = standard_unit.lower().strip() - if from_unit == standard_unit: - return value - conversions = UNIT_CONVERSIONS.get(from_unit, {}) - factor = conversions.get(standard_unit) - if factor is None: - return None - return value * Decimal(str(factor)) - - -def calc_price_per_std_unit( - unit_price: Decimal, - pack_quantity: Optional[int], - unit_size: Optional[Decimal], - unit_size_type: Optional[str], - standard_unit: str, - line_item_unit: Optional[str] = None, -) -> Optional[Decimal]: - """Calculate price per standard unit from source pack data. - - If line_item_unit is a recognised weight/volume unit (kg, g, ml, ltr etc.) - that can convert to standard_unit, use direct conversion instead of pack - formula. This handles cases where the same SKU is sold as a box OR loose - by weight — e.g. carrots at £0.80/kg vs £7.98/10kg box. - """ - if not unit_price: - return None - - # If the line item unit is a measurable weight/volume, convert directly - if line_item_unit: - li_unit = line_item_unit.lower().strip() - if li_unit in UNIT_CONVERSIONS: - direct = convert_to_standard(Decimal("1"), li_unit, standard_unit) - if direct and direct > 0: - return unit_price / direct - - # Fall back to pack formula - if not pack_quantity or not unit_size or not unit_size_type: - return None - total_in_source_unit = Decimal(str(pack_quantity)) * unit_size - total_in_std = convert_to_standard(total_in_source_unit, unit_size_type, standard_unit) - if not total_in_std or total_in_std == 0: - return None - return unit_price / total_in_std - - -def normalize_description(text: str | None) -> str: - """Normalize description for matching — lowercase, collapse whitespace, strip.""" - if not text: - return "" - return " ".join(text.lower().strip().split()) - - -# ── Pydantic schemas ───────────────────────────────────────────────────────── - -class CategoryCreate(BaseModel): - name: str - sort_order: int = 0 - -class CategoryUpdate(BaseModel): - name: Optional[str] = None - sort_order: Optional[int] = None - -class CategoryResponse(BaseModel): - id: int - name: str - sort_order: int - ingredient_count: int = 0 - class Config: - from_attributes = True - -class IngredientCreate(BaseModel): - name: str - category_id: Optional[int] = None - standard_unit: str = "g" - yield_percent: float = 100.0 - manual_price: Optional[float] = None - notes: Optional[str] = None - is_prepackaged: bool = False - is_free: bool = False - product_ingredients: Optional[str] = None - -class IngredientUpdate(BaseModel): - name: Optional[str] = None - category_id: Optional[int] = None - standard_unit: Optional[str] = None - yield_percent: Optional[float] = None - manual_price: Optional[float] = None - notes: Optional[str] = None - is_archived: Optional[bool] = None - is_prepackaged: Optional[bool] = None - is_free: Optional[bool] = None - product_ingredients: Optional[str] = None - -class SourceCreate(BaseModel): - supplier_id: int - product_code: Optional[str] = None - description_pattern: Optional[str] = None - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - latest_unit_price: Optional[float] = None - invoice_id: Optional[int] = None - apply_to_existing: bool = False # Bulk-set ingredient_id on matching line items - -class SourceUpdate(BaseModel): - product_code: Optional[str] = None - description_pattern: Optional[str] = None - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - -class SourceResponse(BaseModel): - id: int - supplier_id: int - supplier_name: str = "" - product_code: Optional[str] = None - description_pattern: Optional[str] = None - description_aliases: list[str] = [] - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - latest_unit_price: Optional[float] = None - latest_invoice_date: Optional[date] = None - price_per_std_unit: Optional[float] = None - matched_line_items: Optional[int] = None # Count of line items bulk-updated - backfilled_count: Optional[int] = None # Count of line items with product codes backfilled - - class Config: - from_attributes = True - -class FlagResponse(BaseModel): - id: int - food_flag_id: int - flag_name: str = "" - flag_code: Optional[str] = None - category_name: str = "" - propagation_type: str = "contains" - source: str = "manual" - -class IngredientResponse(BaseModel): - id: int - name: str - category_id: Optional[int] = None - category_name: Optional[str] = None - standard_unit: str - yield_percent: float - manual_price: Optional[float] = None - notes: Optional[str] = None - is_archived: bool = False - flags_assessed: bool = False - is_prepackaged: bool = False - is_free: bool = False - product_ingredients: Optional[str] = None - has_label_image: bool = False - source_count: int = 0 - effective_price: Optional[float] = None - flags: list[FlagResponse] = [] - none_categories: list[str] = [] - created_at: str = "" - - class Config: - from_attributes = True - -class SimilarIngredient(BaseModel): - id: int - name: str - similarity: float - -class SuggestResponse(BaseModel): - suggestions: list[SimilarIngredient] - - -# ── Category endpoints ─────────────────────────────────────────────────────── - -DEFAULT_INGREDIENT_CATEGORIES = [ - ("Dairy", 0), ("Meat", 1), ("Seafood", 2), ("Produce", 3), - ("Dry Goods", 4), ("Canned & Jarred", 5), ("Frozen Goods", 6), - ("Oils & Fats", 7), ("Herbs & Spices", 7), ("Bakery", 8), - ("Beverages", 9), ("Condiments", 11), ("Other", 12), -] - - -@router.post("/categories/seed-defaults") -async def seed_default_categories( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Seed default ingredient categories. Skips any that already exist by name.""" - kid = user.kitchen_id - created = 0 - for name, sort_order in DEFAULT_INGREDIENT_CATEGORIES: - exists = await db.execute( - select(IngredientCategory).where( - IngredientCategory.kitchen_id == kid, - IngredientCategory.name == name, - ) - ) - if exists.scalar_one_or_none(): - continue - db.add(IngredientCategory(kitchen_id=kid, name=name, sort_order=sort_order)) - created += 1 - await db.commit() - return {"ok": True, "created": created} - - -@router.get("/categories") -async def list_categories( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - # Count ingredients per category - count_sub = ( - select(Ingredient.category_id, func.count(Ingredient.id).label("cnt")) - .where(Ingredient.kitchen_id == user.kitchen_id) - .group_by(Ingredient.category_id) - .subquery() - ) - result = await db.execute( - select(IngredientCategory, func.coalesce(count_sub.c.cnt, 0).label("ingredient_count")) - .outerjoin(count_sub, IngredientCategory.id == count_sub.c.category_id) - .where(IngredientCategory.kitchen_id == user.kitchen_id) - .order_by(IngredientCategory.sort_order, IngredientCategory.name) - ) - rows = result.all() - return [ - CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order, ingredient_count=cnt) - for cat, cnt in rows - ] - - -@router.post("/categories") -async def create_category( - data: CategoryCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - cat = IngredientCategory(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order) - db.add(cat) - await db.commit() - await db.refresh(cat) - return CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order) - - -@router.patch("/categories/{category_id}") -async def update_category( - category_id: int, - data: CategoryUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientCategory).where( - IngredientCategory.id == category_id, - IngredientCategory.kitchen_id == user.kitchen_id, - ) - ) - cat = result.scalar_one_or_none() - if not cat: - raise HTTPException(404, "Category not found") - if data.name is not None: - cat.name = data.name - if data.sort_order is not None: - cat.sort_order = data.sort_order - await db.commit() - return CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order) - - -@router.delete("/categories/{category_id}") -async def delete_category( - category_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientCategory).where( - IngredientCategory.id == category_id, - IngredientCategory.kitchen_id == user.kitchen_id, - ) - ) - cat = result.scalar_one_or_none() - if not cat: - raise HTTPException(404, "Category not found") - # Null out ingredients in this category - await db.execute( - select(Ingredient).where(Ingredient.category_id == category_id) - ) - from sqlalchemy import update - await db.execute( - update(Ingredient).where(Ingredient.category_id == category_id).values(category_id=None) - ) - await db.delete(cat) - await db.commit() - return {"ok": True} - - -# ── Ingredient endpoints ───────────────────────────────────────────────────── - -def _build_ingredient_response(ing: Ingredient, source_count: int = 0, flags: list = None) -> IngredientResponse: - """Build a response dict from an Ingredient model instance.""" - # Calculate effective price from most recent source or manual_price - # (yield is now per recipe-ingredient use, not per raw ingredient) - effective_price = None - if ing.sources: - priced_sources = [s for s in ing.sources if s.price_per_std_unit is not None] - if priced_sources: - latest = max(priced_sources, key=lambda s: s.latest_invoice_date or date.min) - effective_price = float(latest.price_per_std_unit) - if effective_price is None and ing.manual_price: - effective_price = float(ing.manual_price) - - flag_list = [] - if flags: - flag_list = flags - elif hasattr(ing, 'flags') and ing.flags: - flag_list = [ - FlagResponse( - id=f.id, - food_flag_id=f.food_flag_id, - flag_name=f.food_flag.name if f.food_flag else "", - flag_code=f.food_flag.code if f.food_flag else None, - category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "", - propagation_type=f.food_flag.category.propagation_type if f.food_flag and f.food_flag.category else "contains", - source=f.source, - ) - for f in ing.flags - ] - - return IngredientResponse( - id=ing.id, - name=ing.name, - category_id=ing.category_id, - category_name=ing.category.name if ing.category else None, - standard_unit=ing.standard_unit, - yield_percent=float(ing.yield_percent) if ing.yield_percent else 100.0, - manual_price=float(ing.manual_price) if ing.manual_price else None, - notes=ing.notes, - is_archived=ing.is_archived, - flags_assessed=ing.flags_assessed if hasattr(ing, 'flags_assessed') else False, - is_prepackaged=ing.is_prepackaged if hasattr(ing, 'is_prepackaged') else False, - is_free=ing.is_free if hasattr(ing, 'is_free') else False, - product_ingredients=ing.product_ingredients if hasattr(ing, 'product_ingredients') else None, - has_label_image=bool(ing.label_image_path) if hasattr(ing, 'label_image_path') else False, - source_count=source_count or (len(ing.sources) if ing.sources else 0), - effective_price=round(effective_price, 6) if effective_price else None, - flags=flag_list, - none_categories=[ - fn.category.name for fn in ing.flag_nones if fn.category - ] if hasattr(ing, 'flag_nones') and ing.flag_nones else [], - created_at=str(ing.created_at) if ing.created_at else "", - ) - - -@router.get("") -async def list_ingredients( - unmapped: bool = Query(False, description="Filter to ingredients with no sources"), - archived: bool = Query(False, description="Include archived ingredients"), - category_id: Optional[int] = Query(None), - search: Optional[str] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - query = ( - select(Ingredient) - .options( - selectinload(Ingredient.category), - selectinload(Ingredient.sources), - selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category), - selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category), - ) - .where(Ingredient.kitchen_id == user.kitchen_id) - ) - if archived: - query = query.where(Ingredient.is_archived == True) - else: - query = query.where(Ingredient.is_archived == False) - if category_id: - query = query.where(Ingredient.category_id == category_id) - if search: - query = query.where(Ingredient.name.ilike(f"%{search}%")) - - result = await db.execute(query.order_by(Ingredient.name)) - ingredients = result.scalars().all() - - responses = [_build_ingredient_response(ing) for ing in ingredients] - - if unmapped: - responses = [r for r in responses if r.source_count == 0] - - return responses - - -@router.post("/") -@router.post("") -async def create_ingredient( - data: IngredientCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - # Check for exact duplicate name - existing = await db.execute( - select(Ingredient).where( - Ingredient.kitchen_id == user.kitchen_id, - func.lower(Ingredient.name) == data.name.lower().strip(), - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException(409, f"Ingredient '{data.name}' already exists") - - ing = Ingredient( - kitchen_id=user.kitchen_id, - name=data.name.strip(), - category_id=data.category_id, - standard_unit=data.standard_unit, - yield_percent=Decimal(str(data.yield_percent)), - manual_price=Decimal(str(data.manual_price)) if data.manual_price else None, - notes=data.notes, - is_prepackaged=data.is_prepackaged, - is_free=data.is_free, - product_ingredients=data.product_ingredients, - created_by=user.id, - ) - db.add(ing) - await db.commit() - # Re-query with full eager loading to avoid lazy-load in async context - result2 = await db.execute( - select(Ingredient) - .options( - selectinload(Ingredient.category), - selectinload(Ingredient.sources), - selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category), - selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category), - ) - .where(Ingredient.id == ing.id) - ) - ing = result2.scalar_one() - return _build_ingredient_response(ing) - - -@router.get("/suggest") -async def suggest_ingredients( - description: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Suggest existing ingredient matches for a line item description using pg_trgm + ILIKE fallback.""" - result = await db.execute( - text(""" - SELECT i.id, i.name, i.standard_unit, i.yield_percent, - ic.name AS category_name, - similarity(i.name, :desc) AS sim - FROM ingredients i - LEFT JOIN ingredient_categories ic ON ic.id = i.category_id - WHERE i.kitchen_id = :kid - AND i.is_archived = false - AND (similarity(i.name, :desc) > 0.15 OR i.name ILIKE :like) - ORDER BY sim DESC - LIMIT 8 - """), - {"desc": description, "kid": user.kitchen_id, "like": f"%{description}%"}, - ) - rows = result.fetchall() - return [ - { - "id": r.id, - "name": r.name, - "standard_unit": r.standard_unit, - "yield_percent": float(r.yield_percent), - "category_name": r.category_name, - "similarity": round(r.sim, 3), - } - for r in rows - ] - - -@router.get("/check-duplicate") -async def check_duplicate( - name: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Check for similar ingredient names before creation (pg_trgm).""" - result = await db.execute( - text(""" - SELECT id, name, similarity(name, :name) AS sim - FROM ingredients - WHERE kitchen_id = :kid AND similarity(name, :name) > 0.3 - ORDER BY sim DESC - LIMIT 5 - """), - {"name": name, "kid": user.kitchen_id}, - ) - rows = result.fetchall() - return [ - SimilarIngredient(id=r.id, name=r.name, similarity=round(r.sim, 3)) - for r in rows - ] - - -@router.get("/bulk-nones") -async def get_bulk_nones( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get all 'None' category entries for all ingredients in the kitchen (for bulk allergen grid).""" - result = await db.execute( - select(IngredientFlagNone.ingredient_id, IngredientFlagNone.category_id) - .join(Ingredient, Ingredient.id == IngredientFlagNone.ingredient_id) - .where(Ingredient.kitchen_id == user.kitchen_id) - ) - nones = {} - for ing_id, cat_id in result.all(): - if ing_id not in nones: - nones[ing_id] = [] - nones[ing_id].append(cat_id) - return nones - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -# These must be defined BEFORE /{ingredient_id} to avoid path parameter conflicts -@router.get("/ai-match") -async def ai_match_ingredient( - description: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """AI-powered ingredient matching when trigram confidence is low.""" - from services.llm_service import rank_ingredient_matches - - # First get trigram candidates - trgm_result = await db.execute( - text(""" - SELECT i.id, i.name, i.standard_unit, - ic.name AS category_name, - similarity(i.name, :desc) AS sim - FROM ingredients i - LEFT JOIN ingredient_categories ic ON ic.id = i.category_id - WHERE i.kitchen_id = :kid - AND i.is_archived = false - AND (similarity(i.name, :desc) > 0.1 OR i.name ILIKE :like) - ORDER BY sim DESC - LIMIT 20 - """), - {"desc": description, "kid": user.kitchen_id, "like": f"%{description}%"}, - ) - candidates = [ - {"id": r.id, "name": r.name, "standard_unit": r.standard_unit, - "category_name": r.category_name, "similarity": round(r.sim, 3)} - for r in trgm_result.fetchall() - ] - - if not candidates: - return {"llm_status": "unavailable", "ranked": [], "error": None} - - result = await rank_ingredient_matches( - db=db, - kitchen_id=user.kitchen_id, - description=description, - candidates=candidates, - ) - - return { - "llm_status": result["status"], - "ranked": result.get("ranked") or [], - "trigram_candidates": candidates, - "error": result.get("error"), - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.get("/ai-check-duplicate") -async def ai_check_duplicate( - name: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """AI-powered semantic duplicate detection for new ingredient names.""" - from services.llm_service import check_duplicate_ingredient_llm - - # Get top trigram matches - trgm_result = await db.execute( - text(""" - SELECT id, name, similarity(name, :name) AS sim - FROM ingredients - WHERE kitchen_id = :kid AND similarity(name, :name) > 0.15 - ORDER BY sim DESC - LIMIT 30 - """), - {"name": name, "kid": user.kitchen_id}, - ) - existing = [ - {"id": r.id, "name": r.name, "similarity": round(r.sim, 3)} - for r in trgm_result.fetchall() - ] - - if not existing: - return {"llm_status": "unavailable", "duplicates": [], "error": None} - - result = await check_duplicate_ingredient_llm( - db=db, - kitchen_id=user.kitchen_id, - name=name, - existing_ingredients=existing, - ) - - return { - "llm_status": result["status"], - "duplicates": result.get("duplicates") or [], - "error": result.get("error"), - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -# Feature G: AI yield estimation -@router.get("/ai-estimate-yield") -async def ai_estimate_yield( - name: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """AI-powered yield percentage estimation for an ingredient name.""" - from services.llm_service import estimate_yield - - result = await estimate_yield( - db=db, - kitchen_id=user.kitchen_id, - ingredient_name=name, - ) - - return { - "llm_status": result["status"], - "yield_percent": result.get("yield_percent"), - "reason": result.get("reason"), - "error": result.get("error"), - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.get("/ai-pack-size") -async def ai_deduce_pack_size_from_description( - description: str = Query(..., min_length=2), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Deduce pack size from a product description. - Tier 1: regex parse. Tier 2: LLM product knowledge fallback. - Used by IngredientModal when creating ingredients from line items. - """ - from ocr.azure_extractor import parse_pack_size - - # Tier 1: Regex (free, instant) - parsed = parse_pack_size(description) - if parsed["pack_quantity"]: - return { - "source": "regex", - "pack_quantity": parsed["pack_quantity"], - "unit_size": parsed["unit_size"], - "unit_size_type": parsed["unit_size_type"], - "reason": "Parsed from description", - } - - # Tier 2: LLM deduction - from services.llm_service import deduce_pack_size - llm_result = await deduce_pack_size( - db=db, - kitchen_id=user.kitchen_id, - description=description, - ) - - if llm_result["status"] in ("success", "cached") and llm_result["pack_quantity"]: - return { - "source": "ai", - "pack_quantity": llm_result["pack_quantity"], - "unit_size": llm_result["unit_size"], - "unit_size_type": llm_result["unit_size_type"], - "reason": llm_result.get("reason", "AI deduction"), - } - - return { - "source": None, - "pack_quantity": None, - "unit_size": None, - "unit_size_type": None, - "reason": llm_result.get("error") or "Could not determine pack size", - } - - -@router.get("/{ingredient_id}") -async def get_ingredient( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Ingredient) - .options( - selectinload(Ingredient.category), - selectinload(Ingredient.sources).selectinload(IngredientSource.supplier), - selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category), - selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category), - ) - .where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id) - ) - ing = result.scalar_one_or_none() - if not ing: - raise HTTPException(404, "Ingredient not found") - return _build_ingredient_response(ing) - - -@router.get("/{ingredient_id}/label-image") -async def get_label_image( - ingredient_id: int, - token: str, - db: AsyncSession = Depends(get_db), -): - """Serve the stored label image for a prepackaged ingredient. - Uses token query param for auth (allows window.open / img src usage).""" - import os - from auth import get_current_user, require_cap_from_token - user = await get_current_user_from_token(token, db) - if not user: - raise HTTPException(401, "Not authenticated") - result = await db.execute( - select(Ingredient.label_image_path).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - label_path = result.scalar_one_or_none() - if not label_path or not os.path.exists(label_path): - raise HTTPException(404, "Label image not found") - return FileResponse(label_path) - - -@router.patch("/{ingredient_id}") -async def update_ingredient( - ingredient_id: int, - data: IngredientUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Ingredient) - .options(selectinload(Ingredient.category), selectinload(Ingredient.sources)) - .where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id) - ) - ing = result.scalar_one_or_none() - if not ing: - raise HTTPException(404, "Ingredient not found") - - if data.name is not None: - ing.name = data.name.strip() - if data.category_id is not None: - ing.category_id = data.category_id - if data.standard_unit is not None: - ing.standard_unit = data.standard_unit - if data.yield_percent is not None: - ing.yield_percent = Decimal(str(data.yield_percent)) - if data.manual_price is not None: - ing.manual_price = Decimal(str(data.manual_price)) - if data.notes is not None: - ing.notes = data.notes - if data.is_archived is not None: - ing.is_archived = data.is_archived - if data.is_prepackaged is not None: - ing.is_prepackaged = data.is_prepackaged - if data.is_free is not None: - ing.is_free = data.is_free - if data.product_ingredients is not None: - ing.product_ingredients = data.product_ingredients - - await db.commit() - # Re-query with full eager loading to avoid lazy-load in async context - result2 = await db.execute( - select(Ingredient) - .options( - selectinload(Ingredient.category), - selectinload(Ingredient.sources), - selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category), - selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category), - ) - .where(Ingredient.id == ing.id) - ) - ing = result2.scalar_one() - return _build_ingredient_response(ing) - - -@router.delete("/{ingredient_id}") -async def archive_ingredient( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - ing = result.scalar_one_or_none() - if not ing: - raise HTTPException(404, "Ingredient not found") - ing.is_archived = True - await db.commit() - return {"ok": True} - - -# ── Backfill helper ─────────────────────────────────────────────────────────── - -async def backfill_product_codes_for_source( - source: IngredientSource, - kitchen_id: int, - db: AsyncSession, -) -> int: - """ - For a source that has both product_code and description_pattern, - find line items from same supplier where product_code IS NULL and - first line of description matches, then set their product_code and ingredient_id. - Returns count of updated line items. - """ - if not source.product_code or not source.description_pattern: - return 0 - - norm_pattern = normalize_description(source.description_pattern) - if not norm_pattern: - return 0 - - # Find line items from same supplier with no product_code where first-line description matches - result = await db.execute( - text(""" - UPDATE line_items li - SET product_code = :code, - ingredient_id = COALESCE(li.ingredient_id, :ing_id) - FROM invoices inv - WHERE li.invoice_id = inv.id - AND inv.kitchen_id = :kid - AND inv.supplier_id = :sid - AND (li.product_code IS NULL OR li.product_code = '') - AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:pattern) - """), - { - "code": source.product_code, - "ing_id": source.ingredient_id, - "kid": kitchen_id, - "sid": source.supplier_id, - "pattern": norm_pattern, - }, - ) - count = result.rowcount - if count > 0: - logger.info(f"Backfilled product_code '{source.product_code}' on {count} line items for source {source.id}") - return count - - -# ── Source endpoints ────────────────────────────────────────────────────────── - -@router.get("/{ingredient_id}/sources") -async def list_sources( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientSource) - .options(selectinload(IngredientSource.supplier)) - .where( - IngredientSource.ingredient_id == ingredient_id, - IngredientSource.kitchen_id == user.kitchen_id, - ) - .order_by(IngredientSource.created_at) - ) - sources = result.scalars().all() - return [ - SourceResponse( - id=s.id, - supplier_id=s.supplier_id, - supplier_name=s.supplier.name if s.supplier else "", - product_code=s.product_code, - description_pattern=s.description_pattern, - description_aliases=s.description_aliases or [], - pack_quantity=s.pack_quantity, - unit_size=float(s.unit_size) if s.unit_size else None, - unit_size_type=s.unit_size_type, - latest_unit_price=float(s.latest_unit_price) if s.latest_unit_price else None, - latest_invoice_date=str(s.latest_invoice_date) if s.latest_invoice_date else None, - price_per_std_unit=float(s.price_per_std_unit) if s.price_per_std_unit else None, - ) - for s in sources - ] - - -@router.post("/{ingredient_id}/sources") -async def create_source( - ingredient_id: int, - data: SourceCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - # Verify ingredient belongs to kitchen - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - # Validate: at least one of product_code or description_pattern required - if not data.product_code and not data.description_pattern: - raise HTTPException(400, "Either product_code or description_pattern is required") - - # Calculate price_per_std_unit if pack data and price provided - price_per_std = None - unit_conversions = { - 'g': {'g': 1, 'kg': 0.001}, 'kg': {'g': 1000, 'kg': 1}, 'oz': {'g': 28.3495, 'kg': 0.0283495}, - 'ml': {'ml': 1, 'ltr': 0.001}, 'cl': {'ml': 10, 'ltr': 0.01}, 'ltr': {'ml': 1000, 'ltr': 1}, - 'each': {'each': 1}, - } - # Re-fetch ingredient for standard_unit - ing_obj = await db.execute( - select(Ingredient).where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id) - ) - ingredient = ing_obj.scalar_one_or_none() - if data.latest_unit_price and data.unit_size and data.unit_size_type and ingredient: - pq = data.pack_quantity or 1 - conv_factor = unit_conversions.get(data.unit_size_type, {}).get(ingredient.standard_unit, 0) - if conv_factor: - total_std = pq * data.unit_size * conv_factor - price_per_std = Decimal(str(data.latest_unit_price)) / Decimal(str(total_std)) - - # Get invoice date if invoice_id provided - invoice_date = None - if data.invoice_id: - from models.invoice import Invoice - inv_result = await db.execute(select(Invoice.invoice_date).where(Invoice.id == data.invoice_id)) - invoice_date = inv_result.scalar_one_or_none() - - # Check for existing source (upsert: update pack data if already mapped) - existing_conditions = [ - IngredientSource.kitchen_id == user.kitchen_id, - IngredientSource.ingredient_id == ingredient_id, - IngredientSource.supplier_id == data.supplier_id, - ] - if data.product_code: - existing_conditions.append(IngredientSource.product_code == data.product_code) - else: - existing_conditions.append(IngredientSource.product_code.is_(None)) - if data.description_pattern: - existing_conditions.append(func.lower(IngredientSource.description_pattern) == data.description_pattern.lower()) - - existing_src = await db.execute(select(IngredientSource).where(and_(*existing_conditions))) - source = existing_src.scalar_one_or_none() - - if source: - # Update existing source with new pack/price data - source.pack_quantity = data.pack_quantity or 1 - if data.unit_size is not None: - source.unit_size = Decimal(str(data.unit_size)) - if data.unit_size_type: - source.unit_size_type = data.unit_size_type - if data.latest_unit_price is not None: - source.latest_unit_price = Decimal(str(data.latest_unit_price)) - if data.invoice_id: - source.latest_invoice_id = data.invoice_id - if invoice_date: - source.latest_invoice_date = invoice_date - if price_per_std is not None: - source.price_per_std_unit = price_per_std - await db.commit() - else: - source = IngredientSource( - kitchen_id=user.kitchen_id, - ingredient_id=ingredient_id, - supplier_id=data.supplier_id, - product_code=data.product_code, - description_pattern=data.description_pattern, - pack_quantity=data.pack_quantity or 1, - unit_size=Decimal(str(data.unit_size)) if data.unit_size else None, - unit_size_type=data.unit_size_type, - latest_unit_price=Decimal(str(data.latest_unit_price)) if data.latest_unit_price else None, - latest_invoice_id=data.invoice_id, - latest_invoice_date=invoice_date, - price_per_std_unit=price_per_std, - ) - db.add(source) - try: - await db.commit() - except IntegrityError: - await db.rollback() - raise HTTPException(409, "This supplier product is already mapped to this ingredient") - await db.refresh(source) - - # Bulk-set ingredient_id on matching line items if requested - matched_count = None - if data.apply_to_existing: - match_conditions = [ - Invoice.kitchen_id == user.kitchen_id, - Invoice.supplier_id == data.supplier_id, - LineItem.invoice_id == Invoice.id, - LineItem.ingredient_id.is_(None), - ] - if data.product_code: - match_conditions.append(LineItem.product_code == data.product_code) - elif data.description_pattern: - match_conditions.append( - func.lower(LineItem.description).contains(data.description_pattern.lower()) - ) - - result = await db.execute( - update(LineItem) - .where(*match_conditions) - .values(ingredient_id=ingredient_id) - ) - matched_count = result.rowcount - await db.commit() - logger.info(f"Bulk-mapped {matched_count} line items to ingredient {ingredient_id}") - - # Auto-backfill product codes on line items with matching description but missing code - backfilled = await backfill_product_codes_for_source(source, user.kitchen_id, db) - if backfilled > 0: - await db.commit() - - # Load supplier name - sup = await db.execute(select(Supplier).where(Supplier.id == data.supplier_id)) - supplier = sup.scalar_one_or_none() - - return SourceResponse( - id=source.id, - supplier_id=source.supplier_id, - supplier_name=supplier.name if supplier else "", - product_code=source.product_code, - description_pattern=source.description_pattern, - description_aliases=source.description_aliases or [], - pack_quantity=source.pack_quantity, - unit_size=float(source.unit_size) if source.unit_size else None, - unit_size_type=source.unit_size_type, - latest_unit_price=float(source.latest_unit_price) if source.latest_unit_price else None, - latest_invoice_date=source.latest_invoice_date, - price_per_std_unit=float(source.price_per_std_unit) if source.price_per_std_unit else None, - matched_line_items=matched_count, - backfilled_count=backfilled if backfilled > 0 else None, - ) - - -@router.patch("/sources/{source_id}") -async def update_source( - source_id: int, - data: SourceUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientSource).where( - IngredientSource.id == source_id, - IngredientSource.kitchen_id == user.kitchen_id, - ) - ) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(404, "Source not found") - - if data.product_code is not None: - source.product_code = data.product_code - if data.description_pattern is not None: - source.description_pattern = data.description_pattern - if data.pack_quantity is not None: - source.pack_quantity = data.pack_quantity - if data.unit_size is not None: - source.unit_size = Decimal(str(data.unit_size)) - if data.unit_size_type is not None: - source.unit_size_type = data.unit_size_type - - # Recalculate price_per_std_unit if we have price and new pack data - if source.latest_unit_price and source.pack_quantity and source.unit_size and source.unit_size_type: - ing = await db.execute(select(Ingredient).where(Ingredient.id == source.ingredient_id)) - ingredient = ing.scalar_one_or_none() - if ingredient: - source.price_per_std_unit = calc_price_per_std_unit( - source.latest_unit_price, - source.pack_quantity, - source.unit_size, - source.unit_size_type, - ingredient.standard_unit, - ) - - # Auto-backfill product codes if source has both code and description - backfilled = await backfill_product_codes_for_source(source, user.kitchen_id, db) - - await db.commit() - return {"ok": True, "backfilled_count": backfilled} - - -@router.delete("/sources/{source_id}") -async def delete_source( - source_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientSource).where( - IngredientSource.id == source_id, - IngredientSource.kitchen_id == user.kitchen_id, - ) - ) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(404, "Source not found") - await db.delete(source) - await db.commit() - return {"ok": True} - - -# ── Description alias endpoints ─────────────────────────────────────────────── - -class AddAliasRequest(BaseModel): - alias: str - -class AliasSuggestionItem(BaseModel): - description: str - price: Optional[float] = None - -class AliasSuggestionsRequest(BaseModel): - supplier_id: int - items: list[AliasSuggestionItem] - -class BackfillCodesRequest(BaseModel): - source_id: Optional[int] = None - supplier_id: Optional[int] = None - - -@router.post("/sources/{source_id}/aliases") -async def add_description_alias( - source_id: int, - data: AddAliasRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Add a description alias to an ingredient source and normalize matching line items.""" - result = await db.execute( - select(IngredientSource).where( - IngredientSource.id == source_id, - IngredientSource.kitchen_id == user.kitchen_id, - ) - ) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(404, "Source not found") - if not source.description_pattern: - raise HTTPException(400, "Source has no description_pattern to normalize to") - - alias = data.alias.strip() - if not alias: - raise HTTPException(400, "Alias cannot be empty") - - # Add alias (case-insensitive dedup) — create new list for SQLAlchemy change detection - current_aliases = list(source.description_aliases or []) - if alias.lower() not in [a.lower() for a in current_aliases]: - current_aliases.append(alias) - source.description_aliases = current_aliases - - # Bulk-rename line items: same supplier, first line matches alias (case-insensitive) - # 1. Save original description into description_alt (only if not already saved) - # 2. Replace first line of description with master description_pattern - # 3. Set ingredient_id - # Also backfill product_code if source has one - master = source.description_pattern - code_clause = ", product_code = :code" if source.product_code else "" - code_params = {"code": source.product_code} if source.product_code else {} - - rename_result = await db.execute( - text(f""" - UPDATE line_items li - SET description = :master || CASE - WHEN position(E'\\n' IN li.description) > 0 - THEN substring(li.description FROM position(E'\\n' IN li.description)) - ELSE '' - END, - description_alt = CASE - WHEN li.description_alt IS NULL THEN li.description - ELSE li.description_alt - END, - ingredient_id = COALESCE(li.ingredient_id, :ing_id) - {code_clause} - FROM invoices inv - WHERE li.invoice_id = inv.id - AND inv.kitchen_id = :kid - AND inv.supplier_id = :sid - AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:alias) - """), - { - "master": master, - "ing_id": source.ingredient_id, - "kid": user.kitchen_id, - "sid": source.supplier_id, - "alias": alias, - **code_params, - }, - ) - renamed_count = rename_result.rowcount - - await db.commit() - logger.info(f"Added alias '{alias}' to source {source_id}, renamed {renamed_count} line items") - - return {"ok": True, "alias": alias, "renamed_count": renamed_count} - - -@router.post("/sources/alias-suggestions") -async def get_alias_suggestions( - data: AliasSuggestionsRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Find close description matches for unmapped line items from a supplier.""" - # Load all ingredient sources for this supplier + kitchen - src_result = await db.execute( - select(IngredientSource) - .options(selectinload(IngredientSource.ingredient)) - .where( - IngredientSource.kitchen_id == user.kitchen_id, - IngredientSource.supplier_id == data.supplier_id, - ) - ) - sources = src_result.scalars().all() - if not sources: - return [] - - # Build candidate list: (canonical_description, source) - candidates = [] - known_patterns = set() # All known patterns + aliases (lowered) for exact-match skip - for s in sources: - if s.description_pattern: - norm = s.description_pattern.lower().strip() - candidates.append((s.description_pattern, s)) - known_patterns.add(norm) - for alias in (s.description_aliases or []): - known_patterns.add(alias.lower().strip()) - - if not candidates: - return [] - - suggestions = [] - for item in data.items: - desc = item.description.strip() - first_line = desc.split('\n')[0].strip() - if not first_line: - continue - # Skip if already an exact match to a known pattern or alias - if first_line.lower() in known_patterns: - continue - - # Find best match using pg_trgm similarity - best_match = None - best_sim = 0.0 - for pattern, source in candidates: - sim_result = await db.execute( - text("SELECT similarity(:a, :b) AS sim"), - {"a": first_line, "b": pattern}, - ) - sim = float(sim_result.scalar()) - if sim > best_sim and sim >= 0.3: - best_sim = sim - best_match = (pattern, source) - - if best_match: - pattern, source = best_match - # Calculate price difference if available - price_diff = None - if item.price and source.latest_unit_price: - price_diff = round( - abs(item.price - float(source.latest_unit_price)) / float(source.latest_unit_price) * 100, 1 - ) - - suggestions.append({ - "description": first_line, - "source_id": source.id, - "ingredient_id": source.ingredient_id, - "ingredient_name": source.ingredient.name if source.ingredient else None, - "canonical_description": pattern, - "product_code": source.product_code, - "similarity": round(best_sim, 3), - "price_difference": price_diff, - }) - - # Sort by similarity descending - suggestions.sort(key=lambda x: x["similarity"], reverse=True) - return suggestions - - -@router.post("/sources/backfill-codes") -async def backfill_codes( - data: BackfillCodesRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Retroactively fill in missing product codes on line items that match by description.""" - if not data.source_id and not data.supplier_id: - raise HTTPException(400, "Provide either source_id or supplier_id") - - query = select(IngredientSource).where( - IngredientSource.kitchen_id == user.kitchen_id, - IngredientSource.product_code.isnot(None), - IngredientSource.description_pattern.isnot(None), - ) - if data.source_id: - query = query.where(IngredientSource.id == data.source_id) - if data.supplier_id: - query = query.where(IngredientSource.supplier_id == data.supplier_id) - - result = await db.execute(query) - sources = result.scalars().all() - - total_updated = 0 - for source in sources: - count = await backfill_product_codes_for_source(source, user.kitchen_id, db) - total_updated += count - - if total_updated > 0: - await db.commit() - - return {"ok": True, "updated_count": total_updated} - - -# ── Flag endpoints (on ingredients) ────────────────────────────────────────── - -@router.get("/{ingredient_id}/flags") -async def get_ingredient_flags( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(IngredientFlag) - .options(selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category)) - .where(IngredientFlag.ingredient_id == ingredient_id) - ) - flags = result.scalars().all() - return [ - FlagResponse( - id=f.id, - food_flag_id=f.food_flag_id, - flag_name=f.food_flag.name if f.food_flag else "", - flag_code=f.food_flag.code if f.food_flag else None, - category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "", - source=f.source, - ) - for f in flags - ] - - -class FlagSetRequest(BaseModel): - food_flag_ids: list[int] - - -@router.put("/{ingredient_id}/flags") -async def set_ingredient_flags( - ingredient_id: int, - data: FlagSetRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Full replacement of ingredient flags (manual source).""" - # Verify ingredient - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - # Delete existing manual flags (keep latched ones) - await db.execute( - delete(IngredientFlag).where( - IngredientFlag.ingredient_id == ingredient_id, - IngredientFlag.source == "manual", - ) - ) - - # Add new flags - for flag_id in data.food_flag_ids: - # Check if latched flag already exists - existing = await db.execute( - select(IngredientFlag).where( - IngredientFlag.ingredient_id == ingredient_id, - IngredientFlag.food_flag_id == flag_id, - ) - ) - if not existing.scalar_one_or_none(): - db.add(IngredientFlag( - ingredient_id=ingredient_id, - food_flag_id=flag_id, - flagged_by=user.id, - source="manual", - )) - - # When flags are set for a category, remove any "None" entries for that category - if data.food_flag_ids: - # Get category IDs for the flags being set - cat_result = await db.execute( - select(FoodFlag.category_id).where(FoodFlag.id.in_(data.food_flag_ids)).distinct() - ) - cat_ids = [r for r in cat_result.scalars().all()] - if cat_ids: - await db.execute( - delete(IngredientFlagNone).where( - IngredientFlagNone.ingredient_id == ingredient_id, - IngredientFlagNone.category_id.in_(cat_ids), - ) - ) - - await db.commit() - await _bump_recipes_using_ingredient(ingredient_id, db) - return {"ok": True} - - -class FlagNoneRequest(BaseModel): - category_id: int - - -@router.post("/{ingredient_id}/flags/none") -async def toggle_flag_none( - ingredient_id: int, - data: FlagNoneRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Toggle 'None apply' for a specific flag category on an ingredient.""" - # Verify ingredient - result = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - # Check if "None" already exists for this category - existing = await db.execute( - select(IngredientFlagNone).where( - IngredientFlagNone.ingredient_id == ingredient_id, - IngredientFlagNone.category_id == data.category_id, - ) - ) - if existing.scalar_one_or_none(): - # Remove "None" (toggle off) - await db.execute( - delete(IngredientFlagNone).where( - IngredientFlagNone.ingredient_id == ingredient_id, - IngredientFlagNone.category_id == data.category_id, - ) - ) - else: - # Set "None" — also remove any actual flags from this category - flag_ids_result = await db.execute( - select(FoodFlag.id).where(FoodFlag.category_id == data.category_id) - ) - flag_ids = [r for r in flag_ids_result.scalars().all()] - if flag_ids: - await db.execute( - delete(IngredientFlag).where( - IngredientFlag.ingredient_id == ingredient_id, - IngredientFlag.food_flag_id.in_(flag_ids), - ) - ) - db.add(IngredientFlagNone( - ingredient_id=ingredient_id, - category_id=data.category_id, - )) - - await db.commit() - await _bump_recipes_using_ingredient(ingredient_id, db) - return {"ok": True} - - -@router.get("/{ingredient_id}/flags/nones") -async def get_flag_nones( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get category IDs where 'None' is set for an ingredient.""" - result = await db.execute( - select(IngredientFlagNone.category_id).where( - IngredientFlagNone.ingredient_id == ingredient_id, - ) - ) - return {"none_category_ids": [r for r in result.scalars().all()]} - - -# ── Dismissal endpoints (allergen suggestion dismissals) ───────────────────── - -class DismissalCreate(BaseModel): - food_flag_id: int - dismissed_by_name: str - reason: Optional[str] = None - matched_keyword: Optional[str] = None - -class DismissalResponse(BaseModel): - id: int - ingredient_id: int - food_flag_id: int - flag_name: Optional[str] = None - dismissed_by_name: str - reason: Optional[str] = None - matched_keyword: Optional[str] = None - created_at: str = "" - class Config: - from_attributes = True - -class DismissalBatchRequest(BaseModel): - dismissals: list[DismissalCreate] - - -@router.get("/{ingredient_id}/flags/dismissals") -async def get_dismissals( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get all dismissed allergen suggestions for an ingredient.""" - # Verify ingredient belongs to kitchen - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - result = await db.execute( - select(IngredientFlagDismissal) - .options(selectinload(IngredientFlagDismissal.food_flag)) - .where(IngredientFlagDismissal.ingredient_id == ingredient_id) - .order_by(IngredientFlagDismissal.created_at.desc()) - ) - dismissals = result.scalars().all() - return [ - DismissalResponse( - id=d.id, - ingredient_id=d.ingredient_id, - food_flag_id=d.food_flag_id, - flag_name=d.food_flag.name if d.food_flag else None, - dismissed_by_name=d.dismissed_by_name, - reason=d.reason, - matched_keyword=d.matched_keyword, - created_at=str(d.created_at) if d.created_at else "", - ) - for d in dismissals - ] - - -@router.post("/{ingredient_id}/flags/dismissals") -async def create_dismissal( - ingredient_id: int, - data: DismissalCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Dismiss an allergen suggestion for an ingredient (upsert).""" - # Verify ingredient belongs to kitchen - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - # Upsert: check if already dismissed - existing = await db.execute( - select(IngredientFlagDismissal).where( - IngredientFlagDismissal.ingredient_id == ingredient_id, - IngredientFlagDismissal.food_flag_id == data.food_flag_id, - ) - ) - dismissal = existing.scalar_one_or_none() - if dismissal: - # Update existing - dismissal.dismissed_by_name = data.dismissed_by_name - dismissal.reason = data.reason - dismissal.matched_keyword = data.matched_keyword - else: - dismissal = IngredientFlagDismissal( - ingredient_id=ingredient_id, - food_flag_id=data.food_flag_id, - dismissed_by_name=data.dismissed_by_name, - reason=data.reason, - matched_keyword=data.matched_keyword, - ) - db.add(dismissal) - - await db.commit() - await db.refresh(dismissal) - return DismissalResponse( - id=dismissal.id, - ingredient_id=dismissal.ingredient_id, - food_flag_id=dismissal.food_flag_id, - dismissed_by_name=dismissal.dismissed_by_name, - reason=dismissal.reason, - matched_keyword=dismissal.matched_keyword, - created_at=str(dismissal.created_at) if dismissal.created_at else "", - ) - - -@router.post("/{ingredient_id}/flags/dismissals/batch") -async def batch_create_dismissals( - ingredient_id: int, - data: DismissalBatchRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Batch persist dismissals (used after ingredient creation in create mode).""" - # Verify ingredient belongs to kitchen - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - created = 0 - for d in data.dismissals: - # Upsert each - existing = await db.execute( - select(IngredientFlagDismissal).where( - IngredientFlagDismissal.ingredient_id == ingredient_id, - IngredientFlagDismissal.food_flag_id == d.food_flag_id, - ) - ) - if existing.scalar_one_or_none(): - continue # Already dismissed, skip - db.add(IngredientFlagDismissal( - ingredient_id=ingredient_id, - food_flag_id=d.food_flag_id, - dismissed_by_name=d.dismissed_by_name, - reason=d.reason, - matched_keyword=d.matched_keyword, - )) - created += 1 - - await db.commit() - return {"ok": True, "created": created} - - -@router.delete("/{ingredient_id}/flags/dismissals/{dismissal_id}") -async def delete_dismissal( - ingredient_id: int, - dismissal_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Undo a dismissal (re-enables the suggestion).""" - # Verify ingredient belongs to kitchen - ing = await db.execute( - select(Ingredient).where( - Ingredient.id == ingredient_id, - Ingredient.kitchen_id == user.kitchen_id, - ) - ) - if not ing.scalar_one_or_none(): - raise HTTPException(404, "Ingredient not found") - - result = await db.execute( - select(IngredientFlagDismissal).where( - IngredientFlagDismissal.id == dismissal_id, - IngredientFlagDismissal.ingredient_id == ingredient_id, - ) - ) - dismissal = result.scalar_one_or_none() - if not dismissal: - raise HTTPException(404, "Dismissal not found") - - await db.delete(dismissal) - await db.commit() - return {"ok": True} - - -@router.get("/{ingredient_id}/recipes") -async def get_ingredient_recipes( - ingredient_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get recipes that use this ingredient.""" - result = await db.execute( - select(Recipe.id, Recipe.name, Recipe.recipe_type) - .join(RecipeIngredient, RecipeIngredient.recipe_id == Recipe.id) - .where( - RecipeIngredient.ingredient_id == ingredient_id, - Recipe.kitchen_id == user.kitchen_id, - Recipe.is_archived == False, - ) - .order_by(Recipe.name) - ) - return [ - {"id": r.id, "name": r.name, "recipe_type": r.recipe_type} - for r in result.all() - ] - - -# ── Auto-normalize hook (called from invoices.py) ──────────────────────────── - -async def auto_normalize_line_items( - invoice_id: int, - kitchen_id: int, - supplier_id: int, - db: AsyncSession, -) -> int: - """ - Auto-normalize line items on a new invoice: - 1. Fill missing product codes from sources with matching descriptions - 2. Rename descriptions that match known aliases to the master description - Returns total count of line items modified. - """ - # Load all sources for this supplier - src_result = await db.execute( - select(IngredientSource).where( - IngredientSource.kitchen_id == kitchen_id, - IngredientSource.supplier_id == supplier_id, - ) - ) - sources = src_result.scalars().all() - if not sources: - return 0 - - total_updated = 0 - - # Pass 1: Fill missing product codes - for source in sources: - if source.product_code and source.description_pattern: - norm_pattern = normalize_description(source.description_pattern) - if not norm_pattern: - continue - result = await db.execute( - text(""" - UPDATE line_items li - SET product_code = :code, - ingredient_id = COALESCE(li.ingredient_id, :ing_id) - FROM invoices inv - WHERE li.invoice_id = :inv_id - AND li.invoice_id = inv.id - AND (li.product_code IS NULL OR li.product_code = '') - AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:pattern) - """), - { - "code": source.product_code, - "ing_id": source.ingredient_id, - "inv_id": invoice_id, - "pattern": norm_pattern, - }, - ) - total_updated += result.rowcount - - # Pass 2: Rename alias descriptions to master - for source in sources: - if not source.description_pattern or not source.description_aliases: - continue - master = source.description_pattern - code_clause = ", product_code = :code" if source.product_code else "" - code_params = {"code": source.product_code} if source.product_code else {} - - for alias in source.description_aliases: - result = await db.execute( - text(f""" - UPDATE line_items li - SET description = :master || CASE - WHEN position(E'\\n' IN li.description) > 0 - THEN substring(li.description FROM position(E'\\n' IN li.description)) - ELSE '' - END, - description_alt = CASE - WHEN li.description_alt IS NULL THEN li.description - ELSE li.description_alt - END, - ingredient_id = COALESCE(li.ingredient_id, :ing_id) - {code_clause} - WHERE li.invoice_id = :inv_id - AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:alias) - """), - { - "master": master, - "ing_id": source.ingredient_id, - "inv_id": invoice_id, - "alias": alias, - **code_params, - }, - ) - total_updated += result.rowcount - - # Pass 3: Fill missing product codes from sibling line items (same supplier + description) - # This catches cases where no ingredient source exists yet but another invoice - # from the same supplier has the product code for the same description. - result = await db.execute( - text(""" - UPDATE line_items li - SET product_code = known.code - FROM ( - SELECT DISTINCT ON (LOWER(TRIM(split_part(li2.description, E'\\n', 1)))) - LOWER(TRIM(split_part(li2.description, E'\\n', 1))) AS norm_desc, - li2.product_code AS code - FROM line_items li2 - JOIN invoices inv ON inv.id = li2.invoice_id - WHERE inv.supplier_id = :sid - AND li2.product_code IS NOT NULL - AND li2.product_code != '' - ORDER BY LOWER(TRIM(split_part(li2.description, E'\\n', 1))), - li2.id DESC - ) known - WHERE li.invoice_id = :inv_id - AND (li.product_code IS NULL OR li.product_code = '') - AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = known.norm_desc - """), - {"sid": supplier_id, "inv_id": invoice_id}, - ) - total_updated += result.rowcount - - if total_updated > 0: - logger.info(f"Auto-normalized {total_updated} line items on invoice {invoice_id}") - - return total_updated - - -# ── Auto-price hook (called from invoices.py) ──────────────────────────────── - -async def update_ingredient_prices_for_invoice( - invoice_id: int, - kitchen_id: int, - db: AsyncSession, -): - """ - Called after line items are saved/updated for an invoice. - Matches line items to ingredient sources and updates pricing. - Also sets line_item.ingredient_id for matched items. - """ - # Get invoice with supplier - inv_result = await db.execute( - select(Invoice).where(Invoice.id == invoice_id) - ) - invoice = inv_result.scalar_one_or_none() - if not invoice or not invoice.supplier_id: - return - - supplier_id = invoice.supplier_id - invoice_date = invoice.invoice_date - - # Get all line items for this invoice - li_result = await db.execute( - select(LineItem).where(LineItem.invoice_id == invoice_id) - ) - line_items = li_result.scalars().all() - - # Get all ingredient sources for this supplier + kitchen - src_result = await db.execute( - select(IngredientSource) - .where( - IngredientSource.kitchen_id == kitchen_id, - IngredientSource.supplier_id == supplier_id, - ) - ) - sources = src_result.scalars().all() - - if not sources: - return - - # Build lookup structures - code_sources = {} # product_code -> source - desc_sources = [] # [(normalized_pattern, source)] sorted by length desc - - for s in sources: - if s.product_code: - code_sources[s.product_code.strip()] = s - if s.description_pattern: - desc_sources.append((normalize_description(s.description_pattern), s)) - - # Sort description patterns by length descending (longer = more specific) - desc_sources.sort(key=lambda x: len(x[0]), reverse=True) - - updated_ingredients = {} # {ingredient_id: {"name": str, "old_price": float|None, "new_price": float}} - - for li in line_items: - matched_source = None - - # Priority 1: product_code exact match - if li.product_code and li.product_code.strip() in code_sources: - matched_source = code_sources[li.product_code.strip()] - - # Priority 2: description_pattern contains match - if not matched_source and li.description: - norm_desc = normalize_description(li.description) - for pattern, source in desc_sources: - if pattern and pattern in norm_desc: - matched_source = source - break - - if matched_source: - # Always link line item to ingredient for traceability - if not li.ingredient_id: - li.ingredient_id = matched_source.ingredient_id - - # Only update source pricing for real invoices with positive prices - # Skip credit notes and zero/free replacements - if li.unit_price and li.unit_price > 0 and invoice.document_type != 'credit_note': - matched_source.latest_unit_price = li.unit_price - matched_source.latest_invoice_id = invoice_id - matched_source.latest_invoice_date = invoice_date - - # Get ingredient standard unit for conversion - ing_result = await db.execute( - select(Ingredient).where(Ingredient.id == matched_source.ingredient_id) - ) - ingredient = ing_result.scalar_one_or_none() - - if ingredient and (li.unit or (matched_source.pack_quantity and matched_source.unit_size and matched_source.unit_size_type)): - old_price = float(matched_source.price_per_std_unit) if matched_source.price_per_std_unit else None - matched_source.price_per_std_unit = calc_price_per_std_unit( - li.unit_price, - matched_source.pack_quantity, - matched_source.unit_size, - matched_source.unit_size_type, - ingredient.standard_unit, - line_item_unit=li.unit, - ) - new_price = float(matched_source.price_per_std_unit) if matched_source.price_per_std_unit else None - # Only log as a change if the price actually changed - if new_price is not None and (old_price is None or abs(new_price - old_price) > 0.000001): - updated_ingredients[ingredient.id] = { - "name": ingredient.name, - "unit": ingredient.standard_unit or "", - "old_price": old_price, - "new_price": new_price, - } - - return updated_ingredients diff --git a/backend/api/internal.py b/backend/api/internal.py deleted file mode 100644 index 0d9ba2e..0000000 --- a/backend/api/internal.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Internal API — endpoints consumed by other stack apps (not public). -nginx denies /kitchen/api/internal/ from the public side; these endpoints -are called directly on the backend port (8000) from within the docker bridge. -""" -from datetime import date -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from database import get_db -from auth import verify_internal_secret -from models.resos import ResosBooking - -router = APIRouter() - - -@router.get("/api/internal/resos/bookings") -async def internal_resos_bookings( - booking_date: Optional[str] = Query(None, alias="date"), - _: None = Depends(verify_internal_secret), - db: AsyncSession = Depends(get_db), -): - """ - Cached ResOS bookings for a given date — consumed by the KDS app bookings screen. - KDS calls: GET http://10.10.10.110:8000/api/internal/resos/bookings?date=YYYY-MM-DD - Authorization: Bearer {STACK_INTERNAL_SECRET} - """ - try: - target_date = date.fromisoformat(booking_date) if booking_date else date.today() - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD") - - result = await db.execute( - select(ResosBooking) - .where( - ResosBooking.kitchen_id == 1, - ResosBooking.booking_date == target_date, - ) - .order_by(ResosBooking.booking_time) - ) - bookings = result.scalars().all() - - return [ - { - "id": b.id, - "resos_booking_id": b.resos_booking_id, - "booking_date": b.booking_date.isoformat(), - "booking_time": b.booking_time.strftime("%H:%M"), - "people": b.people, - "status": b.status, - "seating_area": b.seating_area, - "table_name": b.table_name, - "hotel_booking_number": b.hotel_booking_number, - "is_hotel_guest": b.is_hotel_guest, - "is_dbb": b.is_dbb, - "allergies": b.allergies, - "notes": b.notes, - "opening_hour_name": b.opening_hour_name, - } - for b in bookings - ] diff --git a/backend/api/invoices.py b/backend/api/invoices.py deleted file mode 100644 index 8e3d686..0000000 --- a/backend/api/invoices.py +++ /dev/null @@ -1,4077 +0,0 @@ -import os -import re -import uuid -import logging -from datetime import date, timedelta -from decimal import Decimal -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks -from fastapi.responses import FileResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, text -from pydantic import BaseModel -import aiofiles - -from database import get_db -from models.user import User -from models.invoice import Invoice, InvoiceStatus -from models.line_item import LineItem -from models.product_definition import ProductDefinition -from models.settings import KitchenSettings -from auth import get_current_user, require_cap -from ocr.extractor import process_invoice_image -from ocr.azure_extractor import parse_pack_size -from services.duplicate_detector import DuplicateDetector - -router = APIRouter() -logger = logging.getLogger(__name__) - - -def normalize_description(text: str | None) -> str: - """Normalize description for matching - lowercase, collapse whitespace, strip""" - if not text: - return "" - return " ".join(text.lower().strip().split()) - - -# Reuse the unit normalization map from parse_pack_size for unit field detection -from ocr.azure_extractor import _UNIT_NORMALIZE as UNIT_FIELD_MAP - - -def detect_pack_size(item) -> tuple: - """ - Auto-detect pack size from a line item using three tiers: - 1. Text parsing of raw_content/description (e.g. "12x100g", "400ml") - 2. Invoice unit field (e.g. unit="KG", qty=2.5 → 1x2.5kg) - Returns (pack_quantity, unit_size, unit_size_type) or the item's existing values. - """ - pq, us, ust = item.pack_quantity, item.unit_size, item.unit_size_type - if pq is not None: - return pq, us, ust - - # Tier 1: Parse from text - detected = parse_pack_size(item.raw_content or item.description or "") - if detected["pack_quantity"]: - pq = detected["pack_quantity"] - us = Decimal(str(detected["unit_size"])) if detected["unit_size"] else us - ust = detected["unit_size_type"] or ust - return pq, us, ust - - # Tier 2: Infer from invoice unit field (KG, LTR, etc.) + quantity - unit_str = (item.unit or "").strip().lower() - std_unit = UNIT_FIELD_MAP.get(unit_str) - if std_unit and item.quantity: - pq = 1 - us = item.quantity # quantity IS the size when priced by weight/volume - ust = std_unit - - return pq, us, ust - - -def get_total_pages_from_ocr(invoice: Invoice) -> int: - """Extract total page count from OCR raw JSON.""" - import json - if not invoice.ocr_raw_json: - return 1 - try: - ocr_data = json.loads(invoice.ocr_raw_json) - pages = ocr_data.get('pages', []) - return len(pages) if pages else 1 - except Exception: - return 1 - - -def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int]: - """ - Parse ocr_raw_json to extract page numbers for each line item. - Returns dict mapping line_number (1-based) -> page_number (1-based) - """ - import json - if not invoice.ocr_raw_json: - return {} - - try: - ocr_data = json.loads(invoice.ocr_raw_json) - documents = ocr_data.get('documents', []) - if not documents: - return {} - - items_field = documents[0].get('fields', {}).get('Items', {}) - ocr_items = items_field.get('value', []) - - # Build mapping: line_number (1-based) -> page_number - result = {} - for idx, ocr_item in enumerate(ocr_items): - bounding_regions = ocr_item.get('bounding_regions', []) - if bounding_regions: - page_num = bounding_regions[0].get('page_number', 1) - else: - page_num = 1 - - # line_number is 1-based (idx + 1) - result[idx + 1] = page_num - - return result - except Exception as e: - logger.warning(f"Failed to parse page numbers from OCR: {e}") - return {} - - -DATA_DIR = "/app/data" - - -# Response Models -class LineItemResponse(BaseModel): - id: int - product_code: str | None - description: str | None - description_alt: str | None # Alternative description (Azure content vs value mismatch) - unit: str | None - quantity: float | None - order_quantity: float | None - unit_price: float | None - tax_rate: str | None - tax_amount: float | None - amount: float | None - line_number: int - is_non_stock: bool - # Pack size fields - raw_content: str | None - pack_quantity: int | None - unit_size: float | None - unit_size_type: str | None - portions_per_unit: int | None # null = not defined yet - cost_per_item: float | None - cost_per_portion: float | None - # OCR warnings for values that needed correction - ocr_warnings: str | None - # Price change detection - price_change_status: str | None = None # "consistent", "amber", "red", "no_history", "acknowledged" - price_change_percent: float | None = None - previous_price: float | None = None - # Future price (for old invoices) - future_price: float | None = None - future_change_percent: float | None = None - # Page number from OCR (for multi-page invoices) - page_number: int | None = None - # Ingredient mapping - ingredient_id: int | None = None - ingredient_name: str | None = None - ingredient_unit: str | None = None - - class Config: - from_attributes = True - - -class LineItemCreate(BaseModel): - product_code: Optional[str] = None - description: Optional[str] = None - unit: Optional[str] = None - quantity: Optional[float] = None - order_quantity: Optional[float] = None - unit_price: Optional[float] = None - tax_rate: Optional[str] = None - tax_amount: Optional[float] = None - amount: Optional[float] = None - is_non_stock: bool = False - # Pack size fields - raw_content: Optional[str] = None - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - portions_per_unit: Optional[int] = None # null = not defined yet - cost_per_item: Optional[float] = None - cost_per_portion: Optional[float] = None - - -class LineItemUpdate(BaseModel): - product_code: Optional[str] = None - description: Optional[str] = None - description_alt: Optional[str] = None # Alternative description (for swap UI) - unit: Optional[str] = None - quantity: Optional[float] = None - order_quantity: Optional[float] = None - unit_price: Optional[float] = None - tax_rate: Optional[str] = None - tax_amount: Optional[float] = None - amount: Optional[float] = None - is_non_stock: Optional[bool] = None - # Pack size fields - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - portions_per_unit: Optional[int] = None - cost_per_item: Optional[float] = None - cost_per_portion: Optional[float] = None - ingredient_id: Optional[int] = None - - -class DuplicateInfo(BaseModel): - id: int - invoice_number: str | None - invoice_date: date | None - total: Decimal | None - supplier_id: int | None - document_type: str | None - duplicate_type: str # "firm_duplicate", "possible_duplicate", "related_document" - - -class InvoiceResponse(BaseModel): - id: int - invoice_number: str | None - invoice_date: date | None - total: Decimal | None - net_total: Decimal | None - stock_total: Decimal | None # Sum of stock items only (non non-stock) - supplier_id: int | None - supplier_name: str | None - supplier_match_type: str | None # "exact", "fuzzy", or null - for highlighting fuzzy matches - supplier_skip_dext: bool = False # Whether supplier has skip_dext enabled - vendor_name: str | None # OCR-extracted vendor name (before supplier matching) - status: str - category: str | None - ocr_confidence: float | None - ocr_raw_text: str | None # OCR extracted text or error message if processing failed - image_path: str - created_at: str - # New fields - document_type: str | None - order_number: str | None - duplicate_status: str | None - duplicate_of_id: int | None - # Dext integration fields - notes: str | None - dext_sent_at: str | None # ISO datetime - dext_sent_by_username: str | None # Resolved from relationship - # Dispute tracking fields - dispute_count: int = 0 - has_open_disputes: bool = False - disputes: list[dict] = [] - disputed_line_item_ids: list[int] = [] # Line item IDs that are part of a dispute - # Source tracking fields - source: str = "upload" - source_reference: str | None = None - # Linked dispute (for credit notes) - linked_dispute_id: int | None = None - # Total pages from OCR (for multi-page invoices) - total_pages: int | None = None - - class Config: - from_attributes = True - - -class InvoiceUpdate(BaseModel): - invoice_number: Optional[str] = None - invoice_date: Optional[date] = None - total: Optional[Decimal] = None - net_total: Optional[Decimal] = None - supplier_id: Optional[int] = None - category: Optional[str] = None - status: Optional[str] = None - # New fields - document_type: Optional[str] = None - order_number: Optional[str] = None - # Dext integration - notes: Optional[str] = None - - -class InvoiceListResponse(BaseModel): - invoices: list[InvoiceResponse] - total: int - - -class DuplicateCompareResponse(BaseModel): - current_invoice: InvoiceResponse - firm_duplicate: InvoiceResponse | None - possible_duplicates: list[InvoiceResponse] - related_documents: list[InvoiceResponse] - - -# Product Definition Models (for persistent portion/pack data) -class ProductDefinitionResponse(BaseModel): - id: int - kitchen_id: int - supplier_id: int | None - product_code: str | None - description_pattern: str | None - pack_quantity: int | None - unit_size: float | None - unit_size_type: str | None - portions_per_unit: int | None - portion_description: str | None - # Saved by metadata - saved_by_user_id: int | None - saved_by_username: str | None # Resolved from saved_by_user relationship - source_invoice_id: int | None - source_invoice_number: str | None - updated_at: str | None # ISO datetime string - - class Config: - from_attributes = True - - -class ProductDefinitionCreate(BaseModel): - supplier_id: Optional[int] = None - product_code: Optional[str] = None - description_pattern: Optional[str] = None - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - portions_per_unit: Optional[int] = None - portion_description: Optional[str] = None - - -class ProductDefinitionUpdate(BaseModel): - pack_quantity: Optional[int] = None - unit_size: Optional[float] = None - unit_size_type: Optional[str] = None - portions_per_unit: Optional[int] = None - portion_description: Optional[str] = None - - -# Line Item Search Models -class LineItemSearchRequest(BaseModel): - query: str - exclude_invoice_id: Optional[int] = None - - -class SearchResultItem(BaseModel): - description: str - unit_price: float | None - unit: str | None - pack_info: str | None - last_invoice_date: str | None - invoice_id: int - similarity: float - - -class SupplierSearchGroup(BaseModel): - supplier_id: int | None - supplier_name: str - items: list[SearchResultItem] - - -class LineItemSearchResponse(BaseModel): - query: str - extracted_keywords: str - results: list[SupplierSearchGroup] - total_matches: int - - -def extract_search_keywords(description: str, supplier_words: list[str] | None = None) -> str: - """Extract meaningful keywords from line item description. - - Removes: - - Pack sizes (12x1L, 120x15g, 6x500ml) - - Quantity patterns (qty 12, case of 24) - - Product codes like (L-AG), [SKU-123] - - Generic terms (case, qty, un, pack, box, each, per, unit, etc.) - - Standalone numbers and weights - - Common English stop words (the, a, an, in, etc.) - - Supplier names and aliases (passed dynamically) - """ - if not description: - return "" - - text = description - - # 1. Remove pack size patterns (12x1L, 120x15g, 6x500ml) - text = re.sub(r'\b\d+\s*x\s*\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl)?\b', '', text, flags=re.IGNORECASE) - - # 2. Remove quantity patterns (qty 12, case of 24) - text = re.sub(r'\b(qty|quantity)\s*:?\s*\d+\b', '', text, flags=re.IGNORECASE) - text = re.sub(r'\bcase\s*(of\s*)?\d+\b', '', text, flags=re.IGNORECASE) - - # 3. Remove product codes like (L-AG), [SKU-123], etc. - text = re.sub(r'\([A-Z]{1,3}-?[A-Z0-9]{1,5}\)', '', text, flags=re.IGNORECASE) - text = re.sub(r'\[[A-Z0-9-]+\]', '', text, flags=re.IGNORECASE) - - # 4. Remove generic packaging/unit terms - generic_terms = ['case', 'qty', 'un', 'pack', 'box', 'each', 'per', 'unit', 'pkt', 'bag', 'bottle', 'tin', 'can', 'carton', 'tray', 'portion', 'portions'] - pattern = r'\b(' + '|'.join(generic_terms) + r')\b' - text = re.sub(pattern, '', text, flags=re.IGNORECASE) - - # 5. Remove standalone numbers and weights (500g, 1.5kg, just "12") - text = re.sub(r'\b\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl|lb)?\b', '', text, flags=re.IGNORECASE) - - # 6. Clean up whitespace and special characters - text = re.sub(r'[^\w\s]', ' ', text) - - # 7. Remove common English stop words - stop_words = [ - 'the', 'a', 'an', 'in', 'on', 'at', 'by', 'for', 'with', 'to', 'of', 'and', 'or', - 'is', 'it', 'as', 'be', 'are', 'was', 'been', 'being', 'have', 'has', 'had', - 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', - 'this', 'that', 'these', 'those', 'from', 'into', 'through', 'during', - 'before', 'after', 'above', 'below', 'between', 'under', 'over' - ] - stop_pattern = r'\b(' + '|'.join(stop_words) + r')\b' - text = re.sub(stop_pattern, '', text, flags=re.IGNORECASE) - - # 8. Remove supplier names and aliases (dynamic list) - if supplier_words: - # Escape special regex chars and filter empty strings - safe_words = [re.escape(w) for w in supplier_words if w and len(w) > 1] - if safe_words: - supplier_pattern = r'\b(' + '|'.join(safe_words) + r')\b' - text = re.sub(supplier_pattern, '', text, flags=re.IGNORECASE) - - # 9. Filter out very short words and collapse whitespace - words = [w for w in text.split() if len(w) > 1] - return ' '.join(words).strip() - - -# Helper function -async def get_invoice_or_404( - invoice_id: int, - current_user: User, - db: AsyncSession -) -> Invoice: - result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - return invoice - - -def invoice_to_response( - invoice: Invoice, - supplier_name: str | None = None, - line_items: list | None = None, - dispute_count: int = 0, - has_open_disputes: bool = False, - disputes: list[dict] = None, - disputed_line_item_ids: list[int] = None -) -> InvoiceResponse: - from sqlalchemy import inspect - - # Get supplier name from relationship if not provided - insp = inspect(invoice) - supplier_skip_dext = False - if supplier_name is None and 'supplier' in insp.dict and invoice.supplier: - supplier_name = invoice.supplier.name - supplier_skip_dext = invoice.supplier.skip_dext or False - elif 'supplier' in insp.dict and invoice.supplier: - supplier_skip_dext = invoice.supplier.skip_dext or False - - # Calculate stock_total from line items (sum of items where is_non_stock=False) - stock_total = None - if line_items is not None: - stock_items = [item for item in line_items if not (item.is_non_stock or False)] - if stock_items: - stock_total = sum(item.amount or Decimal("0") for item in stock_items) - elif 'line_items' in insp.dict and invoice.line_items: - stock_items = [item for item in invoice.line_items if not (item.is_non_stock or False)] - if stock_items: - stock_total = sum(item.amount or Decimal("0") for item in stock_items) - - # Get dext_sent_by_user name from relationship if loaded - dext_sent_by_username = None - if 'dext_sent_by_user' in insp.dict and invoice.dext_sent_by_user: - dext_sent_by_username = invoice.dext_sent_by_user.name - - return InvoiceResponse( - id=invoice.id, - invoice_number=invoice.invoice_number, - invoice_date=invoice.invoice_date, - total=invoice.total, - net_total=invoice.net_total, - stock_total=stock_total, - supplier_id=invoice.supplier_id, - supplier_name=supplier_name, - supplier_match_type=invoice.supplier_match_type, - supplier_skip_dext=supplier_skip_dext, - vendor_name=invoice.vendor_name, - status=invoice.status.value, - category=invoice.category, - ocr_confidence=float(invoice.ocr_confidence) if invoice.ocr_confidence else None, - ocr_raw_text=invoice.ocr_raw_text, - image_path=invoice.image_path, - created_at=invoice.created_at.isoformat(), - document_type=invoice.document_type, - order_number=invoice.order_number, - duplicate_status=invoice.duplicate_status, - duplicate_of_id=invoice.duplicate_of_id, - # Dext integration - notes=invoice.notes, - dext_sent_at=invoice.dext_sent_at.isoformat() if invoice.dext_sent_at else None, - dext_sent_by_username=dext_sent_by_username, - # Dispute tracking - dispute_count=dispute_count, - has_open_disputes=has_open_disputes, - disputes=disputes or [], - disputed_line_item_ids=disputed_line_item_ids or [], - # Source tracking - source=invoice.source or "upload", - source_reference=invoice.source_reference, - # Linked dispute (for credit notes) - linked_dispute_id=invoice.linked_dispute_id, - # Total pages from OCR - total_pages=get_total_pages_from_ocr(invoice) - ) - - -# Invoice endpoints -@router.post("/upload", response_model=InvoiceResponse) -async def upload_invoice( - background_tasks: BackgroundTasks, - file: UploadFile = File(...), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Upload an invoice image or PDF for OCR processing""" - allowed_types = ["image/jpeg", "image/png", "image/webp", "image/heic", "application/pdf"] - if file.content_type not in allowed_types: - raise HTTPException( - status_code=400, - detail=f"File type not allowed. Allowed: {allowed_types}" - ) - - ext = file.filename.split(".")[-1] if file.filename else "jpg" - filename = f"{uuid.uuid4()}.{ext}" - filepath = os.path.join(DATA_DIR, str(current_user.kitchen_id), filename) - - os.makedirs(os.path.dirname(filepath), exist_ok=True) - - async with aiofiles.open(filepath, "wb") as f: - content = await file.read() - await f.write(content) - - invoice = Invoice( - kitchen_id=current_user.kitchen_id, - image_path=filepath, - status=InvoiceStatus.PENDING - ) - db.add(invoice) - await db.commit() - await db.refresh(invoice) - - background_tasks.add_task( - process_invoice_background, - invoice.id, - filepath, - current_user.kitchen_id - ) - - return invoice_to_response(invoice) - - -async def apply_product_definitions( - line_items: list[dict], - kitchen_id: int, - supplier_id: int | None, - db -) -> list[dict]: - """ - Apply product definitions to line items. - Looks up existing definitions and auto-populates portions_per_unit. - Checks for supplier-specific definitions first, then kitchen-wide (supplier_id=NULL). - - Matching priority: - 1. product_code (exact match) - 2. description_pattern (normalized contains match) - used when no product_code or no code match - """ - from sqlalchemy import or_ - - logger.info(f"apply_product_definitions: kitchen_id={kitchen_id}, supplier_id={supplier_id}, line_items_count={len(line_items)}") - - # Build query to get definitions - supplier-specific OR kitchen-wide (supplier_id IS NULL) - conditions = [ - ProductDefinition.kitchen_id == kitchen_id, - ] - - if supplier_id: - # Get both supplier-specific and kitchen-wide definitions - conditions.append( - or_( - ProductDefinition.supplier_id == supplier_id, - ProductDefinition.supplier_id.is_(None) - ) - ) - else: - # Only get kitchen-wide definitions - conditions.append(ProductDefinition.supplier_id.is_(None)) - - result = await db.execute( - select(ProductDefinition).where(*conditions) - ) - all_definitions = result.scalars().all() - logger.info(f"apply_product_definitions: found {len(all_definitions)} definitions") - - # Build lookup dicts - prefer supplier-specific over kitchen-wide - # 1. By product_code (for items with codes) - definitions_by_code = {} - # 2. By description_pattern (for items without codes, or as fallback) - definitions_by_desc = [] # List of (normalized_pattern, definition) tuples - - for d in all_definitions: - # Add to code lookup if has product_code - if d.product_code: - if d.product_code in definitions_by_code: - existing = definitions_by_code[d.product_code] - if existing.supplier_id and not d.supplier_id: - continue # Keep supplier-specific - definitions_by_code[d.product_code] = d - - # Add to description lookup if has description_pattern - if d.description_pattern: - norm_pattern = normalize_description(d.description_pattern) - if norm_pattern: - definitions_by_desc.append((norm_pattern, d)) - - # Sort description patterns: prefer supplier-specific first, then by length (longer = more specific) - definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0]))) - - if not definitions_by_code and not definitions_by_desc: - logger.info("apply_product_definitions: no definitions found after filtering") - return line_items - - logger.info(f"apply_product_definitions: {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions") - - def find_definition(item: dict) -> ProductDefinition | None: - """Find matching definition for a line item""" - product_code = item.get("product_code") - description = item.get("description") - - # Priority 1: Match by product_code - if product_code and product_code in definitions_by_code: - return definitions_by_code[product_code] - - # Priority 2: Match by description_pattern (for items without codes, or when no code match) - if description: - item_desc_norm = normalize_description(description) - for pattern, defn in definitions_by_desc: - # Check if pattern is contained in item description - if pattern in item_desc_norm: - return defn - - return None - - for item in line_items: - defn = find_definition(item) - if not defn: - continue - - match_type = "product_code" if item.get("product_code") and item.get("product_code") in definitions_by_code else "description" - logger.info(f"apply_product_definitions: applying to item (matched by {match_type}): code={item.get('product_code')}, desc={item.get('description', '')[:50]}") - - # Apply portions_per_unit if not already set - if item.get("portions_per_unit") is None and defn.portions_per_unit: - item["portions_per_unit"] = defn.portions_per_unit - # Recalculate cost_per_portion - if item.get("pack_quantity") and item.get("unit_price"): - item["cost_per_portion"] = round( - item["unit_price"] / (item["pack_quantity"] * defn.portions_per_unit), 4 - ) - # Optionally override pack_quantity if definition has it but OCR didn't find it - if item.get("pack_quantity") is None and defn.pack_quantity: - item["pack_quantity"] = defn.pack_quantity - # Calculate cost_per_item - if item.get("unit_price"): - item["cost_per_item"] = round(item["unit_price"] / defn.pack_quantity, 4) - # Recalculate cost_per_portion now that pack_quantity is available - if item.get("portions_per_unit") and item.get("unit_price"): - item["cost_per_portion"] = round( - item["unit_price"] / (defn.pack_quantity * item["portions_per_unit"]), 4 - ) - # Apply unit_size and unit_size_type if definition has them but OCR didn't find them - if item.get("unit_size") is None and defn.unit_size: - item["unit_size"] = float(defn.unit_size) - if item.get("unit_size_type") is None and defn.unit_size_type: - item["unit_size_type"] = defn.unit_size_type - - return line_items - - -async def process_invoice_background(invoice_id: int, image_path: str, kitchen_id: int): - """Background task to process invoice OCR, save line items, and detect duplicates""" - from database import AsyncSessionLocal - - async with AsyncSessionLocal() as db: - try: - result = await process_invoice_image(image_path, kitchen_id, db) - - stmt = select(Invoice).where(Invoice.id == invoice_id) - db_result = await db.execute(stmt) - invoice = db_result.scalar_one() - - # Update basic fields - invoice.invoice_number = result.get("invoice_number") - invoice.invoice_date = result.get("invoice_date") - invoice.total = result.get("total") - invoice.net_total = result.get("net_total") - invoice.supplier_id = result.get("supplier_id") - invoice.supplier_match_type = result.get("supplier_match_type") - invoice.vendor_name = result.get("vendor_name") - invoice.ocr_raw_text = result.get("raw_text") - invoice.ocr_confidence = result.get("confidence") - invoice.document_type = result.get("document_type", "invoice") - invoice.order_number = result.get("order_number") - - # Store raw Azure JSON for debugging/remapping - raw_json = result.get("raw_json") - if raw_json: - import json - invoice.ocr_raw_json = json.dumps(raw_json) - - # Delete existing line items before creating new ones - from sqlalchemy import text - await db.execute( - text("DELETE FROM line_items WHERE invoice_id = :invoice_id"), - {"invoice_id": invoice_id} - ) - await db.flush() - - # Save line items (apply product definitions for auto-population) - line_items = result.get("line_items", []) - supplier_id = result.get("supplier_id") - # Always try to apply definitions - handles both supplier-specific and kitchen-wide - line_items = await apply_product_definitions( - line_items, kitchen_id, supplier_id, db - ) - - for idx, item_data in enumerate(line_items): - line_item = LineItem( - invoice_id=invoice.id, - product_code=item_data.get("product_code"), - description=item_data.get("description"), - description_alt=item_data.get("description_alt"), - unit=item_data.get("unit"), - quantity=Decimal(str(item_data["quantity"])) if item_data.get("quantity") else None, - order_quantity=Decimal(str(item_data["order_quantity"])) if item_data.get("order_quantity") else None, - unit_price=Decimal(str(item_data["unit_price"])) if item_data.get("unit_price") else None, - tax_rate=item_data.get("tax_rate"), - tax_amount=Decimal(str(item_data["tax_amount"])) if item_data.get("tax_amount") else None, - amount=Decimal(str(item_data["amount"])) if item_data.get("amount") else None, - line_number=item_data.get("ocr_index", idx), - # Pack size fields from OCR extraction + product definitions - raw_content=item_data.get("raw_content"), - pack_quantity=item_data.get("pack_quantity"), - unit_size=Decimal(str(item_data["unit_size"])) if item_data.get("unit_size") else None, - unit_size_type=item_data.get("unit_size_type"), - portions_per_unit=item_data.get("portions_per_unit"), # From product definitions - cost_per_item=Decimal(str(item_data["cost_per_item"])) if item_data.get("cost_per_item") else None, - cost_per_portion=Decimal(str(item_data["cost_per_portion"])) if item_data.get("cost_per_portion") else None, - # OCR warnings for values that needed manual review - ocr_warnings=item_data.get("ocr_warnings") - ) - db.add(line_item) - - # Auto-normalize: fill missing product codes + rename alias descriptions - if supplier_id: - try: - from api.ingredients import auto_normalize_line_items - norm_count = await auto_normalize_line_items(invoice_id, kitchen_id, supplier_id, db) - if norm_count: - logger.info(f"Auto-normalized {norm_count} line items on invoice {invoice_id}") - except Exception as e: - logger.warning(f"Auto-normalize failed (non-critical): {e}") - - await db.commit() - await db.refresh(invoice) - - # Auto-update ingredient source prices from matched line items - try: - from api.ingredients import update_ingredient_prices_for_invoice - updated_ingredients = await update_ingredient_prices_for_invoice(invoice_id, kitchen_id, db) - if updated_ingredients: - from api.recipes import snapshot_recipes_using_ingredient - for ing_id, price_info in updated_ingredients.items(): - await snapshot_recipes_using_ingredient( - ing_id, db, - trigger_source=f"ingredient_price_update: invoice #{invoice_id}", - price_info=price_info, - invoice_id=invoice_id, - ) - await db.commit() - logger.info(f"Auto-updated ingredient prices for {len(updated_ingredients)} ingredients from invoice {invoice_id}") - except Exception as e: - logger.warning(f"Ingredient price auto-update failed (non-critical): {e}") - - # Run duplicate detection - detector = DuplicateDetector(db, kitchen_id) - duplicates = await detector.check_duplicates(invoice) - - if duplicates["firm_duplicate"]: - invoice.duplicate_status = "firm_duplicate" - invoice.duplicate_of_id = duplicates["firm_duplicate"].id - elif duplicates["possible_duplicates"]: - invoice.duplicate_status = "possible_duplicate" - invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id - - if duplicates["related_documents"]: - invoice.related_document_id = duplicates["related_documents"][0].id - - invoice.status = InvoiceStatus.PROCESSED - await db.commit() - - logger.info(f"Invoice {invoice_id} processed: number={invoice.invoice_number}, " - f"duplicate_status={invoice.duplicate_status}") - - except Exception as e: - logger.error(f"OCR processing error for invoice {invoice_id}: {e}") - stmt = select(Invoice).where(Invoice.id == invoice_id) - db_result = await db.execute(stmt) - invoice = db_result.scalar_one() - invoice.status = InvoiceStatus.PROCESSED - invoice.ocr_raw_text = f"Error: {str(e)}" - await db.commit() - - -@router.get("/", response_model=InvoiceListResponse) -async def list_invoices( - status: Optional[str] = None, - supplier_id: Optional[int] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - limit: int = 50, - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List invoices for the current kitchen with optional filters - - Status filter supports: - - pending, processed, reviewed, confirmed: filter by specific status - - pending_confirmation: filter by processed OR reviewed (awaiting confirmation) - """ - from sqlalchemy.orm import selectinload - from sqlalchemy import or_ - - # Only load supplier for list view - line_items not needed and slows query significantly - query = select(Invoice).options( - selectinload(Invoice.supplier) - ).where(Invoice.kitchen_id == current_user.kitchen_id) - - # Handle special "pending_confirmation" filter (all non-confirmed: pending, processed, reviewed) - if status == "pending_confirmation": - query = query.where(or_( - Invoice.status == InvoiceStatus.PENDING, - Invoice.status == InvoiceStatus.PROCESSED, - Invoice.status == InvoiceStatus.REVIEWED - )) - elif status: - query = query.where(Invoice.status == status) - if supplier_id: - query = query.where(Invoice.supplier_id == supplier_id) - if date_from: - query = query.where(Invoice.created_at >= date_from) - if date_to: - # Add one day to include the entire end date - query = query.where(Invoice.created_at < date_to + timedelta(days=1)) - - query = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit) - - result = await db.execute(query) - invoices = result.scalars().all() - - # Count query with same filters - count_query = select(func.count(Invoice.id)).where(Invoice.kitchen_id == current_user.kitchen_id) - if status == "pending_confirmation": - count_query = count_query.where(or_( - Invoice.status == InvoiceStatus.PENDING, - Invoice.status == InvoiceStatus.PROCESSED, - Invoice.status == InvoiceStatus.REVIEWED - )) - elif status: - count_query = count_query.where(Invoice.status == status) - if supplier_id: - count_query = count_query.where(Invoice.supplier_id == supplier_id) - if date_from: - count_query = count_query.where(Invoice.created_at >= date_from) - if date_to: - # Add one day to include the entire end date - count_query = count_query.where(Invoice.created_at < date_to + timedelta(days=1)) - count_result = await db.execute(count_query) - total = count_result.scalar() or 0 - - return InvoiceListResponse( - invoices=[invoice_to_response(inv) for inv in invoices], - total=total - ) - - -@router.post("/line-items/search", response_model=LineItemSearchResponse) -async def search_line_items( - request: LineItemSearchRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Search line items across all invoices for the kitchen. - Uses ILIKE keyword matching for search. - Results grouped by supplier with price comparison info. - """ - # Fetch supplier names and aliases for keyword extraction - from models.supplier import Supplier - supplier_result = await db.execute( - select(Supplier.name, Supplier.aliases).where(Supplier.kitchen_id == current_user.kitchen_id) - ) - supplier_rows = supplier_result.fetchall() - - # Build list of supplier words (names + aliases, split into individual words) - supplier_words = [] - for row in supplier_rows: - # Add each word from supplier name - if row.name: - supplier_words.extend(row.name.lower().split()) - # Add each alias and its words - if row.aliases: - for alias in row.aliases: - if alias: - supplier_words.extend(alias.lower().split()) - # Remove duplicates - supplier_words = list(set(supplier_words)) - - # Extract keywords from query - keywords = extract_search_keywords(request.query, supplier_words) if request.query else request.query - - if not keywords or len(keywords) < 2: - return LineItemSearchResponse( - query=request.query, - extracted_keywords=keywords or "", - results=[], - total_matches=0 - ) - - # Build ILIKE patterns from keywords - match ANY keyword (OR logic for better results) - keyword_list = keywords.split() - like_conditions = " OR ".join([f"LOWER(li.description) LIKE LOWER(:kw{i})" for i in range(len(keyword_list))]) - - # Build exclude condition only if exclude_id is provided (avoids asyncpg type inference issue) - exclude_condition = "" - if request.exclude_invoice_id is not None: - exclude_condition = "AND i.id != :exclude_id" - - query = text(f""" - SELECT DISTINCT ON (i.supplier_id, li.description) - li.description, - li.unit_price, - li.unit, - li.pack_quantity, - li.unit_size, - li.unit_size_type, - i.invoice_date, - i.id as invoice_id, - i.supplier_id, - s.name as supplier_name - FROM line_items li - JOIN invoices i ON li.invoice_id = i.id - LEFT JOIN suppliers s ON i.supplier_id = s.id - WHERE i.kitchen_id = :kitchen_id - AND li.description IS NOT NULL - AND li.description != '' - AND ({like_conditions}) - {exclude_condition} - ORDER BY i.supplier_id, li.description, i.invoice_date DESC - LIMIT 100 - """) - - params = { - "kitchen_id": current_user.kitchen_id, - } - if request.exclude_invoice_id is not None: - params["exclude_id"] = request.exclude_invoice_id - for i, kw in enumerate(keyword_list): - params[f"kw{i}"] = f"%{kw}%" - - result = await db.execute(query, params) - rows = result.fetchall() - - # Group results by supplier - supplier_groups: dict[int | None, SupplierSearchGroup] = {} - total_matches = 0 - - for row in rows: - supplier_id = row.supplier_id - supplier_name = row.supplier_name or "Unknown Supplier" - - # Format pack info - pack_info = None - if row.pack_quantity and row.unit_size and row.unit_size_type: - pack_info = f"{row.pack_quantity}x{row.unit_size}{row.unit_size_type}" - elif row.pack_quantity: - pack_info = f"{row.pack_quantity} pack" - - # Format invoice date - last_invoice_date = row.invoice_date.isoformat() if row.invoice_date else None - - item = SearchResultItem( - description=row.description, - unit_price=float(row.unit_price) if row.unit_price else None, - unit=row.unit, - pack_info=pack_info, - last_invoice_date=last_invoice_date, - invoice_id=row.invoice_id, - similarity=1.0 # ILIKE match (no similarity score available) - ) - - if supplier_id not in supplier_groups: - supplier_groups[supplier_id] = SupplierSearchGroup( - supplier_id=supplier_id, - supplier_name=supplier_name, - items=[] - ) - - supplier_groups[supplier_id].items.append(item) - total_matches += 1 - - # Sort groups by supplier name - sorted_groups = sorted(supplier_groups.values(), key=lambda g: g.supplier_name) - - return LineItemSearchResponse( - query=request.query, - extracted_keywords=keywords, - results=sorted_groups, - total_matches=total_matches - ) - - -@router.get("/{invoice_id}", response_model=InvoiceResponse) -async def get_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get a single invoice by ID. - Re-applies latest product definitions to ensure users see current defaults. - """ - from sqlalchemy.orm import selectinload - from sqlalchemy import or_ - - result = await db.execute( - select(Invoice).options( - selectinload(Invoice.supplier), - selectinload(Invoice.line_items), - selectinload(Invoice.dext_sent_by_user) - ).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Re-apply latest product definitions when opening invoice - # This ensures users always see the most current defaults - if invoice.supplier_id and invoice.line_items: - logger.info(f"get_invoice: Re-applying latest product definitions for invoice {invoice_id}") - - # Fetch current product definitions - conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id] - conditions.append( - or_( - ProductDefinition.supplier_id == invoice.supplier_id, - ProductDefinition.supplier_id.is_(None) - ) - ) - - result = await db.execute( - select(ProductDefinition).where(*conditions) - ) - all_definitions = result.scalars().all() - - if all_definitions: - # Build lookup dicts - definitions_by_code = {} - definitions_by_desc = [] - - for d in all_definitions: - if d.product_code: - if d.product_code in definitions_by_code: - existing = definitions_by_code[d.product_code] - if existing.supplier_id and not d.supplier_id: - continue - definitions_by_code[d.product_code] = d - if d.description_pattern: - norm_pattern = normalize_description(d.description_pattern) - if norm_pattern: - definitions_by_desc.append((norm_pattern, d)) - - definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0]))) - - def find_definition_for_item(item: LineItem) -> ProductDefinition | None: - if item.product_code and item.product_code in definitions_by_code: - return definitions_by_code[item.product_code] - if item.description: - item_desc_norm = normalize_description(item.description) - for pattern, defn in definitions_by_desc: - if pattern in item_desc_norm: - return defn - return None - - # Apply definitions to line items - updated_count = 0 - for item in invoice.line_items: - defn = find_definition_for_item(item) - if not defn: - continue - - # Re-apply portions_per_unit from latest definition - if defn.portions_per_unit: - item.portions_per_unit = defn.portions_per_unit - updated_count += 1 - - # Recalculate cost_per_portion - if item.pack_quantity and item.unit_price: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4) - )) - - # Also re-apply pack_quantity if definition has it but line item doesn't - if item.pack_quantity is None and defn.pack_quantity: - item.pack_quantity = defn.pack_quantity - if item.unit_price: - item.cost_per_item = Decimal(str(round(float(item.unit_price) / defn.pack_quantity, 4))) - if item.portions_per_unit: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4) - )) - - # Re-apply unit_size and unit_size_type if definition has them - if item.unit_size is None and defn.unit_size: - item.unit_size = defn.unit_size - if item.unit_size_type is None and defn.unit_size_type: - item.unit_size_type = defn.unit_size_type - - if updated_count > 0: - logger.info(f"get_invoice: Re-applied definitions to {updated_count} line items") - await db.commit() - await db.refresh(invoice) - - # Query disputes for this invoice - from models.dispute import InvoiceDispute, DisputeStatus - - # Fetch all disputes for this invoice (with line_items for disputed line item IDs) - result = await db.execute( - select(InvoiceDispute).options( - selectinload(InvoiceDispute.line_items) - ).where( - InvoiceDispute.invoice_id == invoice_id - ).order_by(InvoiceDispute.opened_at.desc()) - ) - disputes_list = result.scalars().all() - - dispute_count = len(disputes_list) - has_open_disputes = any( - d.status in [DisputeStatus.NEW, DisputeStatus.CONTACTED, DisputeStatus.AWAITING_CREDIT, DisputeStatus.AWAITING_REPLACEMENT] - for d in disputes_list - ) - - # Collect disputed line item IDs from all disputes - disputed_line_item_ids = [] - for d in disputes_list: - for dli in d.line_items: - if dli.invoice_line_item_id and dli.invoice_line_item_id not in disputed_line_item_ids: - disputed_line_item_ids.append(dli.invoice_line_item_id) - - # Format disputes for response - disputes = [ - { - "id": d.id, - "dispute_type": d.dispute_type.value, - "status": d.status.value, - "title": d.title, - "disputed_amount": float(d.disputed_amount) if d.disputed_amount else 0, - "opened_at": d.opened_at.isoformat() - } - for d in disputes_list - ] - - return invoice_to_response(invoice, dispute_count=dispute_count, has_open_disputes=has_open_disputes, disputes=disputes, disputed_line_item_ids=disputed_line_item_ids) - - -@router.get("/{invoice_id}/image") -async def get_invoice_image( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get the invoice image or PDF file (requires auth header)""" - from starlette.responses import Response - from services.file_archival_service import FileArchivalService - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - # Use archival service to get file content (handles local and Nextcloud) - archival_service = FileArchivalService(db, current_user.kitchen_id) - success, result = await archival_service.get_file_content(invoice) - - if not success: - raise HTTPException(status_code=404, detail=f"File not found: {result}") - - ext = invoice.image_path.split(".")[-1].lower() - media_types = { - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "png": "image/png", - "webp": "image/webp", - "heic": "image/heic", - "pdf": "application/pdf", - } - media_type = media_types.get(ext, "application/octet-stream") - - return Response( - content=result, - media_type=media_type, - headers={ - "Access-Control-Allow-Origin": "*", - "Cross-Origin-Resource-Policy": "cross-origin", - "Cache-Control": "max-age=3600", - } - ) - - -@router.get("/{invoice_id}/file") -async def get_invoice_file( - invoice_id: int, - token: str, - db: AsyncSession = Depends(get_db) -): - """Get invoice file (image or PDF) with token in query param - works through proxies""" - from auth import get_current_user, require_cap_from_token - from starlette.responses import Response - from services.file_archival_service import FileArchivalService - - # Verify token and get user - current_user = await get_current_user_from_token(token, db) - if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - # Use archival service to get file content (handles local and Nextcloud) - archival_service = FileArchivalService(db, current_user.kitchen_id) - success, result = await archival_service.get_file_content(invoice) - - if not success: - raise HTTPException(status_code=404, detail=f"File not found: {result}") - - ext = invoice.image_path.split(".")[-1].lower() - media_types = { - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "png": "image/png", - "webp": "image/webp", - "heic": "image/heic", - "pdf": "application/pdf", - } - media_type = media_types.get(ext, "application/octet-stream") - - return Response( - content=result, - media_type=media_type, - headers={ - "Content-Disposition": "inline", - "Access-Control-Allow-Origin": "*", - "Cross-Origin-Resource-Policy": "cross-origin", - "X-Frame-Options": "SAMEORIGIN", - "Content-Security-Policy": "frame-ancestors 'self'", - "Cache-Control": "no-cache", - } - ) - - -@router.get("/{invoice_id}/pdf") -async def get_invoice_pdf( - invoice_id: int, - token: str, - db: AsyncSession = Depends(get_db) -): - """Get invoice PDF with token in query param (for iframe embedding) - DEPRECATED, use /file""" - from auth import get_current_user, require_cap_from_token - from starlette.responses import Response - - # Verify token and get user - current_user = await get_current_user_from_token(token, db) - if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - if not os.path.exists(invoice.image_path): - raise HTTPException(status_code=404, detail="File not found") - - # Read file and return with headers that allow iframe/object embedding through proxies - with open(invoice.image_path, "rb") as f: - content = f.read() - - return Response( - content=content, - media_type="application/pdf", - headers={ - "Content-Disposition": "inline", - "Access-Control-Allow-Origin": "*", - "Cross-Origin-Resource-Policy": "cross-origin", - "Cache-Control": "no-cache", - } - ) - - -@router.patch("/{invoice_id}", response_model=InvoiceResponse) -async def update_invoice( - invoice_id: int, - update: InvoiceUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update invoice data (for manual corrections)""" - from sqlalchemy import or_ - from models.dispute import InvoiceDispute, DisputeStatus - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - # Track if supplier is being changed - old_supplier_id = invoice.supplier_id - update_data = update.model_dump(exclude_unset=True) - new_supplier_id = update_data.get("supplier_id") - supplier_changed = new_supplier_id is not None and new_supplier_id != old_supplier_id - - # Check for open disputes before confirming invoice - if update_data.get("status") == "CONFIRMED": - result = await db.execute( - select(func.count(InvoiceDispute.id)).where( - InvoiceDispute.invoice_id == invoice_id, - InvoiceDispute.status.in_([ - DisputeStatus.OPEN, - DisputeStatus.CONTACTED, - DisputeStatus.IN_PROGRESS, - DisputeStatus.AWAITING_CREDIT, - DisputeStatus.AWAITING_REPLACEMENT - ]) - ) - ) - open_dispute_count = result.scalar() or 0 - - if open_dispute_count > 0: - raise HTTPException( - status_code=400, - detail={ - "error": "cannot_confirm_with_disputes", - "message": f"Cannot confirm invoice with {open_dispute_count} open dispute(s). Resolve all disputes before confirming.", - "dispute_count": open_dispute_count - } - ) - - # Check invoice has a date set before confirming - # Use the new date if being updated, otherwise check existing - new_date = update_data.get("invoice_date") - effective_date = new_date if new_date is not None else invoice.invoice_date - if not effective_date: - raise HTTPException( - status_code=400, - detail={ - "error": "cannot_confirm_without_date", - "message": "Cannot confirm invoice without a date. Please set the invoice date before confirming." - } - ) - - for field, value in update_data.items(): - if field == "status" and value: - setattr(invoice, field, InvoiceStatus(value)) - else: - setattr(invoice, field, value) - - await db.commit() - await db.refresh(invoice) - - # If supplier changed, auto-apply product definitions to line items - if supplier_changed and new_supplier_id: - logger.info(f"update_invoice: supplier changed from {old_supplier_id} to {new_supplier_id}, applying definitions") - - # Get all line items for this invoice - result = await db.execute( - select(LineItem) - .where(LineItem.invoice_id == invoice_id) - ) - line_items = result.scalars().all() - - if line_items: - # Get definitions for the new supplier (or kitchen-wide) - conditions = [ - ProductDefinition.kitchen_id == current_user.kitchen_id, - or_( - ProductDefinition.supplier_id == new_supplier_id, - ProductDefinition.supplier_id.is_(None) - ) - ] - - result = await db.execute( - select(ProductDefinition).where(*conditions) - ) - all_definitions = result.scalars().all() - - # Build lookup dicts - prefer supplier-specific over kitchen-wide - definitions_by_code = {} - definitions_by_desc = [] # List of (normalized_pattern, definition) tuples - - for d in all_definitions: - if d.product_code: - if d.product_code in definitions_by_code: - existing = definitions_by_code[d.product_code] - if existing.supplier_id and not d.supplier_id: - continue - definitions_by_code[d.product_code] = d - if d.description_pattern: - norm_pattern = normalize_description(d.description_pattern) - if norm_pattern: - definitions_by_desc.append((norm_pattern, d)) - - # Sort description patterns: prefer supplier-specific first, then by length - definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0]))) - - logger.info(f"update_invoice: found {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions for supplier {new_supplier_id}") - - def find_definition_for_item(item: LineItem) -> ProductDefinition | None: - if item.product_code and item.product_code in definitions_by_code: - return definitions_by_code[item.product_code] - if item.description: - item_desc_norm = normalize_description(item.description) - for pattern, defn in definitions_by_desc: - if pattern in item_desc_norm: - return defn - return None - - # Apply definitions to line items - for item in line_items: - defn = find_definition_for_item(item) - if not defn: - continue - - match_type = "product_code" if item.product_code and item.product_code in definitions_by_code else "description" - logger.info(f"update_invoice: applying definition (matched by {match_type}) to item: code={item.product_code}, desc={item.description[:50] if item.description else ''}") - - # Only update if portions_per_unit is not already set - if item.portions_per_unit is None and defn.portions_per_unit: - item.portions_per_unit = defn.portions_per_unit - - # Recalculate cost_per_portion - if item.pack_quantity and item.unit_price: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4) - )) - - # Also apply pack_quantity if OCR didn't find it - if item.pack_quantity is None and defn.pack_quantity: - item.pack_quantity = defn.pack_quantity - if item.unit_price: - item.cost_per_item = Decimal(str( - round(float(item.unit_price) / defn.pack_quantity, 4) - )) - if item.portions_per_unit and item.unit_price: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4) - )) - # Apply unit_size and unit_size_type if definition has them - if item.unit_size is None and defn.unit_size: - item.unit_size = defn.unit_size - if item.unit_size_type is None and defn.unit_size_type: - item.unit_size_type = defn.unit_size_type - - await db.commit() - - # Auto-update PDF highlights/notes overlay when notes change (if annotations enabled) - if "notes" in update_data: - try: - from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items - from sqlalchemy.orm import selectinload - from models.settings import KitchenSettings - import json - - # Check if PDF annotations are enabled in settings - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if settings and settings.pdf_annotations_enabled: - # Load full invoice with all line items and OCR data - invoice_result = await db.execute( - select(Invoice).options( - selectinload(Invoice.line_items) - ).where(Invoice.id == invoice_id) - ) - full_invoice = invoice_result.scalar_one_or_none() - - if full_invoice and full_invoice.ocr_raw_json and os.path.exists(full_invoice.image_path): - ocr_data = json.loads(full_invoice.ocr_raw_json) - ocr_line_items = parse_azure_ocr_line_items(ocr_data) - - non_stock_items = [item for item in full_invoice.line_items if item.is_non_stock] - - # Create backup if doesn't exist (safety measure) - import shutil - backup_path = full_invoice.image_path + '.original' - if not os.path.exists(backup_path): - shutil.copy2(full_invoice.image_path, backup_path) - - # Regenerate all highlights/notes (clears existing annotations first) - # This works even with empty ocr_line_items - notes overlay still gets added - highlighter = PDFHighlighter(full_invoice.image_path) - highlighter.highlight_items_with_ocr_data( - ocr_line_items=ocr_line_items, - non_stock_line_items=non_stock_items, - output_path=full_invoice.image_path, - notes=full_invoice.notes, - ocr_data=ocr_data - ) - logger.info(f"Auto-updated PDF notes overlay: notes={'updated' if full_invoice.notes else 'cleared'}") - - except Exception as e: - logger.warning(f"Failed to auto-update PDF notes overlay: {e}") - - # Auto-send to Dext when invoice is confirmed (if setting enabled) - # Only send if status is changing TO confirmed and not already sent - if update_data.get("status") == "CONFIRMED" and not invoice.dext_sent_at: - try: - from models.settings import KitchenSettings - from services.email_service import EmailService, generate_dext_email_html, generate_dext_email_plain - from sqlalchemy.orm import selectinload - - # Check if auto-send is enabled - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if settings and settings.dext_auto_send_enabled and settings.dext_email: - # Validate SMTP is configured - if settings.smtp_host and settings.smtp_from_email: - # Load full invoice with line items and supplier - invoice_result = await db.execute( - select(Invoice).options( - selectinload(Invoice.line_items), - selectinload(Invoice.supplier) - ).where(Invoice.id == invoice_id) - ) - full_invoice = invoice_result.scalar_one_or_none() - - # Skip if supplier has skip_dext enabled - if full_invoice and full_invoice.supplier and full_invoice.supplier.skip_dext: - logger.info(f"Skipping Dext auto-send for invoice {invoice_id}: supplier '{full_invoice.supplier.name}' has skip_dext enabled") - elif full_invoice and full_invoice.image_path and os.path.exists(full_invoice.image_path): - # Update PDF highlights if annotations enabled - if settings.dext_include_annotations: - try: - from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items - import json - - ocr_data = json.loads(full_invoice.ocr_raw_json) if full_invoice.ocr_raw_json else {} - ocr_line_items = parse_azure_ocr_line_items(ocr_data) - - if ocr_line_items: - non_stock_items = [item for item in full_invoice.line_items if item.is_non_stock] - - import shutil - backup_path = full_invoice.image_path + '.original' - if not os.path.exists(backup_path): - shutil.copy2(full_invoice.image_path, backup_path) - - highlighter = PDFHighlighter(full_invoice.image_path) - highlighter.highlight_items_with_ocr_data( - ocr_line_items=ocr_line_items, - non_stock_line_items=non_stock_items, - output_path=full_invoice.image_path, - notes=full_invoice.notes, - ocr_data=ocr_data - ) - except Exception as e: - logger.warning(f"Dext auto-send: PDF highlighting failed: {e}") - - # Read file - with open(full_invoice.image_path, 'rb') as f: - file_bytes = f.read() - - ext = full_invoice.image_path.split('.')[-1].lower() - filename = f"{full_invoice.invoice_number or 'invoice'}_{full_invoice.invoice_date.strftime('%Y%m%d') if full_invoice.invoice_date else 'unknown'}.{ext}" - - supplier_name = full_invoice.supplier.name if full_invoice.supplier else None - html_body = generate_dext_email_html( - invoice=full_invoice, - supplier_name=supplier_name, - line_items=full_invoice.line_items, - notes=full_invoice.notes, - include_notes=settings.dext_include_notes, - include_non_stock=settings.dext_include_non_stock - ) - plain_body = generate_dext_email_plain( - invoice=full_invoice, - supplier_name=supplier_name, - line_items=full_invoice.line_items, - notes=full_invoice.notes, - include_notes=settings.dext_include_notes, - include_non_stock=settings.dext_include_non_stock - ) - - email_service = EmailService(settings) - subject = f"Invoice {full_invoice.invoice_number or 'N/A'} - {supplier_name or 'Unknown Supplier'}" - - success = email_service.send_email( - to_email=settings.dext_email, - subject=subject, - html_body=html_body, - plain_body=plain_body, - attachments=[(filename, file_bytes)] - ) - - if success: - # Update dext status in database - from datetime import datetime - now = datetime.utcnow() - logger.info(f"Dext auto-send success, updating dext_sent_at for invoice {invoice_id}") - - # Use raw SQL update to ensure it's committed properly - from sqlalchemy import update as sql_update - update_result = await db.execute( - sql_update(Invoice) - .where(Invoice.id == invoice_id) - .values(dext_sent_at=now, dext_sent_by_user_id=current_user.id) - ) - logger.info(f"SQL update affected {update_result.rowcount} rows") - await db.commit() - - # Verify the update by querying fresh - verify_result = await db.execute( - select(Invoice.dext_sent_at).where(Invoice.id == invoice_id) - ) - verified_value = verify_result.scalar_one_or_none() - logger.info(f"Verified dext_sent_at in DB: {verified_value}") - - # Update the invoice object for the response - invoice.dext_sent_at = now - invoice.dext_sent_by_user_id = current_user.id - logger.info(f"Auto-sent invoice {invoice_id} to Dext on confirm, invoice.dext_sent_at={invoice.dext_sent_at}") - else: - logger.warning(f"Dext auto-send failed for invoice {invoice_id}") - else: - logger.warning(f"Dext auto-send: Invoice file not found for {invoice_id}") - else: - logger.debug("Dext auto-send: SMTP not configured") - except Exception as e: - logger.warning(f"Dext auto-send failed: {e}") - - return invoice_to_response(invoice) - - -@router.delete("/{invoice_id}") -async def delete_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete an invoice""" - from sqlalchemy import update - from services.file_archival_service import FileArchivalService - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - # Clear any references from other invoices pointing to this one - await db.execute( - update(Invoice) - .where(Invoice.duplicate_of_id == invoice_id) - .values(duplicate_of_id=None, duplicate_status=None) - ) - await db.execute( - update(Invoice) - .where(Invoice.related_document_id == invoice_id) - .values(related_document_id=None) - ) - - # Handle file deletion (copies to deleted folder if on Nextcloud) - archival_service = FileArchivalService(db, current_user.kitchen_id) - await archival_service.handle_invoice_deletion(invoice) - - await db.delete(invoice) - await db.commit() - - return {"message": "Invoice deleted"} - - -# Duplicate detection endpoint -@router.get("/{invoice_id}/duplicates", response_model=DuplicateCompareResponse) -async def get_invoice_duplicates( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get duplicate comparison info for an invoice""" - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - detector = DuplicateDetector(db, current_user.kitchen_id) - duplicates = await detector.check_duplicates(invoice) - - return DuplicateCompareResponse( - current_invoice=invoice_to_response(invoice), - firm_duplicate=invoice_to_response(duplicates["firm_duplicate"]) if duplicates["firm_duplicate"] else None, - possible_duplicates=[invoice_to_response(d) for d in duplicates["possible_duplicates"]], - related_documents=[invoice_to_response(d) for d in duplicates["related_documents"]] - ) - - -# Line item endpoints -@router.get("/{invoice_id}/line-items", response_model=list[LineItemResponse]) -async def get_line_items( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get line items for an invoice with price change detection""" - from services.price_history import PriceHistoryService - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - from sqlalchemy.orm import selectinload - result = await db.execute( - select(LineItem) - .options(selectinload(LineItem.ingredient)) - .where(LineItem.invoice_id == invoice_id) - .order_by(LineItem.line_number) - ) - items = result.scalars().all() - - # Get page numbers from OCR data (maps line_number -> page_number) - page_numbers = get_line_item_page_numbers_by_line_number(invoice) - - # Initialize price history service for price status calculation - price_service = PriceHistoryService(db, current_user.kitchen_id) - - responses = [] - for item in items: - # Calculate price status if item has unit_price and supplier - price_change_status = None - price_change_percent = None - previous_price = None - future_price = None - future_change_percent = None - - if item.unit_price and invoice.supplier_id: - try: - status = await price_service.get_price_status( - supplier_id=invoice.supplier_id, - product_code=item.product_code, - description=item.description, - current_price=item.unit_price, - unit=item.unit, - current_invoice_id=invoice_id, - reference_date=invoice.invoice_date - ) - price_change_status = status.status - price_change_percent = status.change_percent - previous_price = float(status.previous_price) if status.previous_price else None - future_price = float(status.future_price) if status.future_price else None - future_change_percent = status.future_change_percent - except Exception as e: - logger.warning(f"Failed to get price status for line item {item.id}: {e}") - - # Auto-detect pack size from description/raw_content/unit field - pq, us, ust = detect_pack_size(item) - - responses.append(LineItemResponse( - id=item.id, - product_code=item.product_code, - description=item.description, - description_alt=item.description_alt, - unit=item.unit, - quantity=float(item.quantity) if item.quantity else None, - order_quantity=float(item.order_quantity) if item.order_quantity else None, - unit_price=float(item.unit_price) if item.unit_price else None, - tax_rate=item.tax_rate, - tax_amount=float(item.tax_amount) if item.tax_amount else None, - amount=float(item.amount) if item.amount else None, - line_number=item.line_number, - is_non_stock=item.is_non_stock or False, - raw_content=item.raw_content, - pack_quantity=pq, - unit_size=float(us) if us else None, - unit_size_type=ust, - portions_per_unit=item.portions_per_unit, - cost_per_item=float(item.cost_per_item) if item.cost_per_item else None, - cost_per_portion=float(item.cost_per_portion) if item.cost_per_portion else None, - ocr_warnings=item.ocr_warnings, - price_change_status=price_change_status, - price_change_percent=price_change_percent, - previous_price=previous_price, - future_price=future_price, - future_change_percent=future_change_percent, - page_number=page_numbers.get(item.line_number, 1), - ingredient_id=item.ingredient_id, - ingredient_name=item.ingredient.name if item.ingredient else None, - ingredient_unit=item.ingredient.standard_unit if item.ingredient else None, - )) - - return responses - - -@router.post("/{invoice_id}/line-items", response_model=LineItemResponse) -async def add_line_item( - invoice_id: int, - item: LineItemCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Add a new line item to an invoice""" - await get_invoice_or_404(invoice_id, current_user, db) - - result = await db.execute( - select(func.max(LineItem.line_number)) - .where(LineItem.invoice_id == invoice_id) - ) - max_num = result.scalar() or -1 - - # Calculate costs if pack_quantity and unit_price provided - cost_per_item = None - cost_per_portion = None - if item.pack_quantity and item.unit_price: - cost_per_item = round(item.unit_price / item.pack_quantity, 4) - # Only calculate cost_per_portion if portions_per_unit is explicitly set - if item.portions_per_unit: - cost_per_portion = round(item.unit_price / (item.pack_quantity * item.portions_per_unit), 4) - - line_item = LineItem( - invoice_id=invoice_id, - product_code=item.product_code, - description=item.description, - unit=item.unit, - quantity=Decimal(str(item.quantity)) if item.quantity else None, - order_quantity=Decimal(str(item.order_quantity)) if item.order_quantity else None, - unit_price=Decimal(str(item.unit_price)) if item.unit_price else None, - tax_rate=item.tax_rate, - tax_amount=Decimal(str(item.tax_amount)) if item.tax_amount else None, - amount=Decimal(str(item.amount)) if item.amount else None, - line_number=max_num + 1, - is_non_stock=item.is_non_stock, - raw_content=item.raw_content, - pack_quantity=item.pack_quantity, - unit_size=Decimal(str(item.unit_size)) if item.unit_size else None, - unit_size_type=item.unit_size_type, - portions_per_unit=item.portions_per_unit, - cost_per_item=Decimal(str(cost_per_item)) if cost_per_item else None, - cost_per_portion=Decimal(str(cost_per_portion)) if cost_per_portion else None - ) - db.add(line_item) - await db.commit() - await db.refresh(line_item) - - # Auto-detect pack size from description/raw_content/unit field - pq, us, ust = detect_pack_size(line_item) - - return LineItemResponse( - id=line_item.id, - product_code=line_item.product_code, - description=line_item.description, - description_alt=line_item.description_alt, - unit=line_item.unit, - quantity=float(line_item.quantity) if line_item.quantity else None, - order_quantity=float(line_item.order_quantity) if line_item.order_quantity else None, - unit_price=float(line_item.unit_price) if line_item.unit_price else None, - tax_rate=line_item.tax_rate, - tax_amount=float(line_item.tax_amount) if line_item.tax_amount else None, - amount=float(line_item.amount) if line_item.amount else None, - line_number=line_item.line_number, - is_non_stock=line_item.is_non_stock or False, - raw_content=line_item.raw_content, - pack_quantity=pq, - unit_size=float(us) if us else None, - unit_size_type=ust, - portions_per_unit=line_item.portions_per_unit, # Return actual value (null if not defined) - cost_per_item=float(line_item.cost_per_item) if line_item.cost_per_item else None, - cost_per_portion=float(line_item.cost_per_portion) if line_item.cost_per_portion else None, - ocr_warnings=line_item.ocr_warnings - ) - - -@router.patch("/{invoice_id}/line-items/{item_id}", response_model=LineItemResponse) -async def update_line_item( - invoice_id: int, - item_id: int, - update: LineItemUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update a line item""" - await get_invoice_or_404(invoice_id, current_user, db) - - result = await db.execute( - select(LineItem).where( - LineItem.id == item_id, - LineItem.invoice_id == invoice_id - ) - ) - line_item = result.scalar_one_or_none() - if not line_item: - raise HTTPException(status_code=404, detail="Line item not found") - - update_data = update.model_dump(exclude_unset=True) - decimal_fields = ["quantity", "order_quantity", "unit_price", "tax_amount", "amount", "unit_size", "cost_per_item", "cost_per_portion"] - for field, value in update_data.items(): - if value is not None and field in decimal_fields: - setattr(line_item, field, Decimal(str(value))) - else: - setattr(line_item, field, value) - - # Clear OCR warnings if user manually corrects any of the affected fields - ocr_warning_fields = {"quantity", "unit_price", "amount", "unit_size", "pack_quantity"} - if ocr_warning_fields & set(update_data.keys()): - line_item.ocr_warnings = None - - # Recalculate costs if pack fields or unit_price changed - recalc_fields = {"pack_quantity", "portions_per_unit", "unit_price"} - if recalc_fields & set(update_data.keys()): - if line_item.pack_quantity and line_item.unit_price: - line_item.cost_per_item = Decimal(str( - round(float(line_item.unit_price) / line_item.pack_quantity, 4) - )) - # Only calculate cost_per_portion if portions_per_unit is explicitly set - if line_item.portions_per_unit: - line_item.cost_per_portion = Decimal(str( - round(float(line_item.unit_price) / (line_item.pack_quantity * line_item.portions_per_unit), 4) - )) - else: - line_item.cost_per_portion = None - - await db.commit() - await db.refresh(line_item) - - # Auto-update ingredient source price when unit_price changes - if "unit_price" in update_data: - try: - from api.ingredients import update_ingredient_prices_for_invoice - from api.recipes import snapshot_recipes_using_ingredient - updated_ingredients = await update_ingredient_prices_for_invoice(invoice_id, current_user.kitchen_id, db) - if updated_ingredients: - for ing_id, price_info in updated_ingredients.items(): - await snapshot_recipes_using_ingredient( - ing_id, db, - trigger_source=f"line_item_update: #{item_id}", - price_info=price_info, - invoice_id=invoice_id, - ) - await db.commit() - except Exception as e: - logger.warning(f"Ingredient price auto-update failed: {e}") - - # Auto-update PDF highlights when is_non_stock status changes (if annotations enabled) - if "is_non_stock" in update_data: - try: - from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items - from sqlalchemy.orm import selectinload - from models.settings import KitchenSettings - import json - - # Check if PDF annotations are enabled in settings - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if settings and settings.pdf_annotations_enabled: - # Load full invoice with all line items and OCR data - invoice_result = await db.execute( - select(Invoice).options( - selectinload(Invoice.line_items) - ).where(Invoice.id == invoice_id) - ) - invoice = invoice_result.scalar_one_or_none() - - if invoice and invoice.ocr_raw_json and os.path.exists(invoice.image_path): - ocr_data = json.loads(invoice.ocr_raw_json) - ocr_line_items = parse_azure_ocr_line_items(ocr_data) - - non_stock_items = [item for item in invoice.line_items if item.is_non_stock] - - # Create backup if doesn't exist (safety measure) - import shutil - backup_path = invoice.image_path + '.original' - if not os.path.exists(backup_path): - shutil.copy2(invoice.image_path, backup_path) - - # Regenerate all highlights (clears existing, adds for current non-stock items) - # Works even with empty ocr_line_items - notes overlay still preserved - highlighter = PDFHighlighter(invoice.image_path) - highlighter.highlight_items_with_ocr_data( - ocr_line_items=ocr_line_items, - non_stock_line_items=non_stock_items, - output_path=invoice.image_path, - notes=invoice.notes, - ocr_data=ocr_data - ) - logger.info(f"Auto-updated PDF highlights: {len(non_stock_items)} non-stock items") - - except Exception as e: - logger.warning(f"Failed to auto-update PDF highlights: {e}") - - # Auto-detect pack size from description/raw_content/unit field - pq, us, ust = detect_pack_size(line_item) - - return LineItemResponse( - id=line_item.id, - product_code=line_item.product_code, - description=line_item.description, - description_alt=line_item.description_alt, - unit=line_item.unit, - quantity=float(line_item.quantity) if line_item.quantity else None, - order_quantity=float(line_item.order_quantity) if line_item.order_quantity else None, - unit_price=float(line_item.unit_price) if line_item.unit_price else None, - tax_rate=line_item.tax_rate, - tax_amount=float(line_item.tax_amount) if line_item.tax_amount else None, - amount=float(line_item.amount) if line_item.amount else None, - line_number=line_item.line_number, - is_non_stock=line_item.is_non_stock or False, - raw_content=line_item.raw_content, - pack_quantity=pq, - unit_size=float(us) if us else None, - unit_size_type=ust, - portions_per_unit=line_item.portions_per_unit, # Return actual value (null if not defined) - cost_per_item=float(line_item.cost_per_item) if line_item.cost_per_item else None, - cost_per_portion=float(line_item.cost_per_portion) if line_item.cost_per_portion else None, - ocr_warnings=line_item.ocr_warnings - ) - - -@router.delete("/{invoice_id}/line-items/{item_id}") -async def delete_line_item( - invoice_id: int, - item_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete a line item""" - await get_invoice_or_404(invoice_id, current_user, db) - - result = await db.execute( - select(LineItem).where( - LineItem.id == item_id, - LineItem.invoice_id == invoice_id - ) - ) - line_item = result.scalar_one_or_none() - if not line_item: - raise HTTPException(status_code=404, detail="Line item not found") - - await db.delete(line_item) - await db.commit() - - return {"message": "Line item deleted"} - - -# Raw OCR data endpoint -@router.get("/{invoice_id}/ocr-data") -async def get_invoice_ocr_data( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get the raw OCR data (text and JSON) for an invoice""" - import json as json_module - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - raw_json = None - if invoice.ocr_raw_json: - try: - raw_json = json_module.loads(invoice.ocr_raw_json) - except json_module.JSONDecodeError: - raw_json = None - - return { - "invoice_id": invoice.id, - "raw_text": invoice.ocr_raw_text, - "raw_json": raw_json, - "confidence": float(invoice.ocr_confidence) if invoice.ocr_confidence else None - } - - -@router.get("/{invoice_id}/line-items/{line_number}/preview") -async def get_line_item_preview( - invoice_id: int, - line_number: int, - token: str, - db: AsyncSession = Depends(get_db) -): - """Get a cropped image preview of a specific line item from the invoice OCR bounding box.""" - import json as json_module - import io - from auth import get_current_user, require_cap_from_token - from starlette.responses import Response - from services.file_archival_service import FileArchivalService - - current_user = await get_current_user_from_token(token, db) - if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - # Parse OCR JSON to get bounding box - if not invoice.ocr_raw_json: - raise HTTPException(status_code=404, detail="No OCR data") - - try: - ocr_json = json_module.loads(invoice.ocr_raw_json) - except json_module.JSONDecodeError: - raise HTTPException(status_code=404, detail="Invalid OCR data") - - # Navigate to line item bounding box - try: - items = ocr_json["documents"][0]["fields"]["Items"]["value"] - if line_number < 0 or line_number >= len(items): - raise HTTPException(status_code=404, detail="Line number out of range") - region = items[line_number].get("bounding_regions", [{}])[0] - polygon = region.get("polygon") - page_number = region.get("page_number", 1) - if not polygon or len(polygon) < 4: - raise HTTPException(status_code=404, detail="No bounding box") - except (KeyError, IndexError): - raise HTTPException(status_code=404, detail="No bounding box data") - - # Calculate bounding box from polygon - xs = [p[0] for p in polygon] - ys = [p[1] for p in polygon] - x0_inches, x1_inches = min(xs), max(xs) - y0_inches, y1_inches = min(ys), max(ys) - - # Get page dimensions - pages = ocr_json.get("pages", []) - page_info = pages[page_number - 1] if page_number <= len(pages) else {} - page_w_inches = page_info.get("width", 8.5) - page_h_inches = page_info.get("height", 11) - - # Padding in inches - pad = 0.15 - - # Get the file content - archival_service = FileArchivalService(db, current_user.kitchen_id) - success, file_bytes = await archival_service.get_file_content(invoice) - if not success: - raise HTTPException(status_code=404, detail="File not found") - - ext = invoice.image_path.split(".")[-1].lower() - - if ext == "pdf": - # PDF: use PyMuPDF to render page crop - import fitz - doc = fitz.open(stream=file_bytes, filetype="pdf") - page = doc[page_number - 1] - - # Convert inches to points (72 pts/inch) - rect = fitz.Rect( - (x0_inches - pad) * 72, - (y0_inches - pad) * 72, - (x1_inches + pad) * 72, - (y1_inches + pad) * 72, - ) - rect = rect & page.rect # clip to page bounds - - pixmap = page.get_pixmap(clip=rect, dpi=200) - img_bytes = pixmap.tobytes("png") - doc.close() - return Response(content=img_bytes, media_type="image/png", headers={"Cache-Control": "max-age=3600"}) - else: - # Image: use Pillow to crop - from PIL import Image - img = Image.open(io.BytesIO(file_bytes)) - w, h = img.size - - # Convert inch percentages to pixels - crop_box = ( - max(0, int((x0_inches / page_w_inches - pad / page_w_inches) * w)), - max(0, int((y0_inches / page_h_inches - pad / page_h_inches) * h)), - min(w, int((x1_inches / page_w_inches + pad / page_w_inches) * w)), - min(h, int((y1_inches / page_h_inches + pad / page_h_inches) * h)), - ) - cropped = img.crop(crop_box) - buf = io.BytesIO() - cropped.save(buf, format="PNG") - return Response(content=buf.getvalue(), media_type="image/png", headers={"Cache-Control": "max-age=3600"}) - - -# Field name mapping: URL param -> Azure OCR field key -_FIELD_KEY_MAP = { - "product_code": "ProductCode", - "description": "Description", - "unit_price": "UnitPrice", - "amount": "Amount", - "quantity": "Quantity", -} - - -@router.get("/{invoice_id}/line-items/{line_number}/preview/field/{field_name}") -async def get_line_item_field_preview( - invoice_id: int, - line_number: int, - field_name: str, - token: str, - db: AsyncSession = Depends(get_db), -): - """Get a cropped image preview of a specific field within a line item (e.g. product_code).""" - import json as json_module - import io - from auth import get_current_user, require_cap_from_token - from starlette.responses import Response - from services.file_archival_service import FileArchivalService - - azure_key = _FIELD_KEY_MAP.get(field_name) - if not azure_key: - raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}") - - current_user = await get_current_user_from_token(token, db) - if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - if not invoice.ocr_raw_json: - raise HTTPException(status_code=404, detail="No OCR data") - - try: - ocr_json = json_module.loads(invoice.ocr_raw_json) - except json_module.JSONDecodeError: - raise HTTPException(status_code=404, detail="Invalid OCR data") - - # Navigate to the specific field's bounding box within the line item - try: - items = ocr_json["documents"][0]["fields"]["Items"]["value"] - if line_number < 0 or line_number >= len(items): - raise HTTPException(status_code=404, detail="Line number out of range") - item_value = items[line_number].get("value", {}) - field_data = item_value.get(azure_key) - if not field_data: - raise HTTPException(status_code=404, detail=f"Field '{field_name}' not found in line item") - region = field_data.get("bounding_regions", [{}])[0] - polygon = region.get("polygon") - page_number = region.get("page_number", 1) - if not polygon or len(polygon) < 4: - raise HTTPException(status_code=404, detail="No bounding box for field") - except (KeyError, IndexError): - raise HTTPException(status_code=404, detail="No bounding box data") - - xs = [p[0] for p in polygon] - ys = [p[1] for p in polygon] - x0_inches, x1_inches = min(xs), max(xs) - y0_inches, y1_inches = min(ys), max(ys) - - pages = ocr_json.get("pages", []) - page_info = pages[page_number - 1] if page_number <= len(pages) else {} - page_w_inches = page_info.get("width", 8.5) - page_h_inches = page_info.get("height", 11) - - pad = 0.05 # smaller padding for individual fields - - archival_service = FileArchivalService(db, current_user.kitchen_id) - success, file_bytes = await archival_service.get_file_content(invoice) - if not success: - raise HTTPException(status_code=404, detail="File not found") - - ext = invoice.image_path.split(".")[-1].lower() - - if ext == "pdf": - import fitz - doc = fitz.open(stream=file_bytes, filetype="pdf") - page = doc[page_number - 1] - rect = fitz.Rect( - (x0_inches - pad) * 72, - (y0_inches - pad) * 72, - (x1_inches + pad) * 72, - (y1_inches + pad) * 72, - ) - rect = rect & page.rect - pixmap = page.get_pixmap(clip=rect, dpi=200) - img_bytes = pixmap.tobytes("png") - doc.close() - return Response(content=img_bytes, media_type="image/png", headers={"Cache-Control": "max-age=3600"}) - else: - from PIL import Image - img = Image.open(io.BytesIO(file_bytes)) - w, h = img.size - crop_box = ( - max(0, int((x0_inches / page_w_inches - pad / page_w_inches) * w)), - max(0, int((y0_inches / page_h_inches - pad / page_h_inches) * h)), - min(w, int((x1_inches / page_w_inches + pad / page_w_inches) * w)), - min(h, int((y1_inches / page_h_inches + pad / page_h_inches) * h)), - ) - cropped = img.crop(crop_box) - buf = io.BytesIO() - cropped.save(buf, format="PNG") - return Response(content=buf.getvalue(), media_type="image/png", headers={"Cache-Control": "max-age=3600"}) - - -@router.get("/{invoice_id}/parse-dates") -async def parse_dates_from_ocr( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Parse potential dates from invoice OCR raw text content""" - import json as _json - from datetime import datetime - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - raw_text = invoice.ocr_raw_text or "" - raw_json: dict = {} - if invoice.ocr_raw_json: - try: - raw_json = _json.loads(invoice.ocr_raw_json) - except Exception: - pass - - # Date patterns to look for (UK/EU formats primarily) - # DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD - date_patterns = [ - (r'\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b', 'dmy'), # DD.MM.YYYY or DD/MM/YYYY - (r'\b(\d{1,2})-(\d{1,2})-(\d{4})\b', 'dmy'), # DD-MM-YYYY - (r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b', 'ymd'), # YYYY-MM-DD (ISO) - ] - - found_dates = [] - seen_dates = set() # Avoid duplicates - - for pattern, fmt in date_patterns: - matches = re.finditer(pattern, raw_text) - for match in matches: - try: - if fmt == 'dmy': - day, month, year = int(match.group(1)), int(match.group(2)), int(match.group(3)) - else: # ymd - year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) - - # Validate date - if 1 <= day <= 31 and 1 <= month <= 12 and 1900 <= year <= 2100: - parsed_date = datetime(year, month, day).date() - date_str = parsed_date.isoformat() - - if date_str not in seen_dates: - seen_dates.add(date_str) - - start = max(0, match.start() - 50) - end = min(len(raw_text), match.end() + 20) - context = raw_text[start:end].replace('\n', ' ').strip() - - found_dates.append({ - "date": date_str, - "original": match.group(0), - "context": context, - "bbox": _find_word_bbox(raw_json, match.group(0)), - }) - except (ValueError, IndexError): - continue - - # Sort by date (most recent first might be invoice date) - found_dates.sort(key=lambda x: x["date"], reverse=True) - - return { - "invoice_id": invoice.id, - "current_date": invoice.invoice_date.isoformat() if invoice.invoice_date else None, - "found_dates": found_dates - } - - -def _generalize_invoice_number_pattern(sample: str) -> str: - """ - Convert a known invoice number into a regex that matches similar-shaped numbers. - Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers. - e.g. 'ID304574' → r'\bID\d{5,7}\b' - 'INV-00123' → r'\bINV-\d{4,6}\b' - """ - parts = [] - i = 0 - while i < len(sample): - c = sample[i] - if c.isdigit(): - j = i - while j < len(sample) and sample[j].isdigit(): - j += 1 - run = j - i - # Tight ±1 to avoid false positives like phone/VAT numbers - parts.append(rf'\d{{{max(1, run - 1)},{run + 1}}}') - i = j - elif c.isalpha(): - j = i - while j < len(sample) and sample[j].isalpha(): - j += 1 - run = j - i - letters = sample[i:j] - if run <= 4: - parts.append(re.escape(letters)) # short prefix — exact match - else: - parts.append(rf'[A-Za-z]{{{run - 1},{run + 1}}}') - i = j - else: - parts.append(re.escape(c)) - i += 1 - return r'\b' + ''.join(parts) + r'\b' - - -def _find_word_bbox(raw_json: dict, text: str) -> dict | None: - """ - Search Azure OCR page words for a token exactly matching `text`. - Returns bbox as percentages of page dimensions so it can be used with - the same canvas-crop logic as field bounding boxes. - Handles both flat polygon [x1,y1,x2,y2,...] and nested [[x1,y1],...] formats. - """ - text_norm = text.strip().upper() - for page_idx, page in enumerate(raw_json.get('pages', [])): - page_width = page.get('width', 8.5) or 8.5 - page_height = page.get('height', 11.0) or 11.0 - for word in page.get('words', []): - if word.get('content', '').strip().upper() != text_norm: - continue - polygon = word.get('polygon', []) - if not polygon: - continue - # Flat: [x1,y1,x2,y2,...] vs nested: [[x1,y1],[x2,y2],...] - if isinstance(polygon[0], (int, float)): - xs = polygon[0::2] - ys = polygon[1::2] - else: - xs = [p[0] for p in polygon] - ys = [p[1] for p in polygon] - if not xs or not ys: - continue - return { - 'x': (min(xs) / page_width) * 100, - 'y': (min(ys) / page_height) * 100, - 'width': ((max(xs) - min(xs)) / page_width) * 100, - 'height': ((max(ys) - min(ys)) / page_height) * 100, - 'pageNumber': page_idx + 1, - } - return None - - -def _field_bbox(raw_json: dict, field_name: str) -> dict | None: - """Extract bbox from an Azure structured document field.""" - try: - region = raw_json['documents'][0]['fields'][field_name]['bounding_regions'][0] - polygon = region['polygon'] - page_number = region.get('page_number', 1) - page = raw_json['pages'][page_number - 1] - page_width = page.get('width', 8.5) or 8.5 - page_height = page.get('height', 11.0) or 11.0 - if isinstance(polygon[0], (int, float)): - xs = polygon[0::2]; ys = polygon[1::2] - else: - xs = [p[0] for p in polygon]; ys = [p[1] for p in polygon] - return { - 'x': (min(xs) / page_width) * 100, - 'y': (min(ys) / page_height) * 100, - 'width': ((max(xs) - min(xs)) / page_width) * 100, - 'height': ((max(ys) - min(ys)) / page_height) * 100, - 'pageNumber': page_number, - } - except (KeyError, IndexError, TypeError): - return None - - -# Context signals that indicate a match is NOT an invoice number -_INVOICE_NUM_EXCLUDE = re.compile( - r'\b(?:tel|fax|mob(?:ile)?|phone|' - r'vat\s*reg|vat\s*no|vat\s*number|vat\s*registration|' - r'[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}|' # email address - r'[A-Z]{1,2}\d{1,2}[A-Z]?\s?\d[A-Z]{2})', # UK postcode - re.IGNORECASE -) - - -@router.get("/{invoice_id}/parse-invoice-number") -async def parse_invoice_number_from_ocr( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Search invoice OCR text for candidate invoice numbers. - Uses past confirmed invoice numbers from the same supplier to derive - a format pattern, then scans the raw text for matches. - Falls back to keyword-proximity heuristics when no history is available. - """ - import json as _json - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - raw_text = invoice.ocr_raw_text or "" - - candidates = [] - seen = set() - raw_json: dict = {} - if invoice.ocr_raw_json: - try: - raw_json = _json.loads(invoice.ocr_raw_json) - except Exception: - pass - - def add_candidate(value: str, context: str, source: str, bbox=None): - value = value.strip() - if not value or value in seen: - return - seen.add(value) - entry = {"value": value, "context": context, "source": source} - if bbox: - entry["bbox"] = bbox - candidates.append(entry) - - # ── 1. Check Azure OCR structured fields that might contain the number ────── - if raw_json: - try: - docs = raw_json.get("documents", [{}]) - fields = docs[0].get("fields", {}) if docs else {} - for field_name in ("InvoiceId", "TransactionId", "PurchaseOrder", "PaymentRef", - "OrderId", "DocumentNumber", "BillingReference"): - field = fields.get(field_name) - if field and field.get("value"): - val = str(field["value"]).strip() - bbox = _field_bbox(raw_json, field_name) or _find_word_bbox(raw_json, val) - add_candidate(val, f"Azure OCR field: {field_name}", "ocr_field", bbox) - except Exception: - pass - - # ── 2. Derive format pattern from past confirmed invoice numbers ───────────── - past_numbers: list[str] = [] - if invoice.supplier_id: - result = await db.execute( - select(Invoice.invoice_number) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.supplier_id == invoice.supplier_id, - Invoice.invoice_number.isnot(None), - Invoice.id != invoice_id, - Invoice.status == InvoiceStatus.CONFIRMED, - ) - .order_by(Invoice.id.desc()) - .limit(15) - ) - past_numbers = [r[0] for r in result.fetchall() if r[0]] - - if past_numbers and raw_text: - seen_shapes: set[str] = set() - patterns_to_try: list[str] = [] - for num in past_numbers: - shape = re.sub(r'\d', 'N', re.sub(r'[A-Za-z]', 'A', num)) - if shape not in seen_shapes: - seen_shapes.add(shape) - patterns_to_try.append(_generalize_invoice_number_pattern(num)) - if len(patterns_to_try) >= 5: - break - - for pattern in patterns_to_try: - try: - for match in re.finditer(pattern, raw_text, re.IGNORECASE): - val = match.group(0).strip() - if len(val) < 4: - continue - if val in past_numbers: - continue - ctx_start = max(0, match.start() - 80) - ctx_end = min(len(raw_text), match.end() + 80) - if _INVOICE_NUM_EXCLUDE.search(raw_text[ctx_start:ctx_end]): - continue - start = max(0, match.start() - 50) - end = min(len(raw_text), match.end() + 30) - context = raw_text[start:end].replace('\n', ' ').strip() - bbox = _find_word_bbox(raw_json, val) - add_candidate(val, context, "supplier_pattern", bbox) - except re.error: - continue - - # ── 3. Keyword-proximity fallback ──────────────────────────────────────────── - keyword_pattern = re.compile( - r'(?:invoice\s*(?:no|number|num|#|ref)?|inv\s*(?:no|#)|' - r'our\s*ref|tax\s*point|order\s*ref|reference|ref\s*no|' - r'document\s*(?:no|number))[:\s#]*([A-Z0-9][A-Z0-9\-/\.]{2,24})', - re.IGNORECASE - ) - for match in re.finditer(keyword_pattern, raw_text): - val = match.group(1).strip().rstrip('.') - start = max(0, match.start() - 10) - end = min(len(raw_text), match.end() + 20) - context = raw_text[start:end].replace('\n', ' ').strip() - bbox = _find_word_bbox(raw_json, val) - add_candidate(val, context, "keyword_match", bbox) - - return { - "invoice_id": invoice.id, - "current_number": invoice.invoice_number, - "supplier_examples": past_numbers[:5], - "candidates": candidates, - } - - -@router.post("/reprocess-all") -async def reprocess_all_invoices( - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Reprocess all non-confirmed invoices through OCR. - Clears existing extracted data and line items, then re-runs OCR processing. - """ - # Get all non-confirmed invoices - result = await db.execute( - select(Invoice).where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.status != InvoiceStatus.CONFIRMED - ) - ) - invoices = result.scalars().all() - count = len(invoices) - - if count == 0: - return {"message": "No invoices to reprocess", "count": 0} - - # Queue each invoice for reprocessing - for invoice in invoices: - # Clear existing line items - await db.execute( - select(LineItem).where(LineItem.invoice_id == invoice.id) - ) - # Delete line items for this invoice - from sqlalchemy import delete - await db.execute( - delete(LineItem).where(LineItem.invoice_id == invoice.id) - ) - - # Reset invoice status to pending - invoice.status = InvoiceStatus.PENDING - invoice.supplier_id = None - invoice.supplier_match_type = None - invoice.invoice_number = None - invoice.invoice_date = None - invoice.total = None - invoice.net_total = None - invoice.vendor_name = None - invoice.ocr_raw_text = None - invoice.ocr_raw_json = None - invoice.ocr_confidence = None - invoice.document_type = None - invoice.order_number = None - invoice.duplicate_status = None - invoice.duplicate_of_id = None - - # Queue background processing - background_tasks.add_task( - process_invoice_background, - invoice.id, - invoice.image_path, - current_user.kitchen_id - ) - - await db.commit() - - return {"message": f"Queued {count} invoices for reprocessing", "count": count} - - -# Product Definition endpoints -@router.get("/product-definitions/", response_model=list[ProductDefinitionResponse]) -async def list_product_definitions( - supplier_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List product definitions for the current kitchen""" - from sqlalchemy.orm import selectinload - - query = select(ProductDefinition).options( - selectinload(ProductDefinition.saved_by_user), - selectinload(ProductDefinition.source_invoice) - ).where( - ProductDefinition.kitchen_id == current_user.kitchen_id - ) - if supplier_id: - query = query.where(ProductDefinition.supplier_id == supplier_id) - - result = await db.execute(query.order_by(ProductDefinition.product_code)) - definitions = result.scalars().all() - - return [ - ProductDefinitionResponse( - id=d.id, - kitchen_id=d.kitchen_id, - supplier_id=d.supplier_id, - product_code=d.product_code, - description_pattern=d.description_pattern, - pack_quantity=d.pack_quantity, - unit_size=float(d.unit_size) if d.unit_size else None, - unit_size_type=d.unit_size_type, - portions_per_unit=d.portions_per_unit, - portion_description=d.portion_description, - saved_by_user_id=d.saved_by_user_id, - saved_by_username=d.saved_by_user.name if d.saved_by_user else None, - source_invoice_id=d.source_invoice_id, - source_invoice_number=d.source_invoice.invoice_number if d.source_invoice else None, - updated_at=d.updated_at.isoformat() if d.updated_at else None - ) - for d in definitions - ] - - -@router.post("/product-definitions/", response_model=ProductDefinitionResponse) -async def create_product_definition( - definition: ProductDefinitionCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Create a new product definition""" - # Check if definition already exists for this product_code + supplier - if definition.product_code: - result = await db.execute( - select(ProductDefinition).where( - ProductDefinition.kitchen_id == current_user.kitchen_id, - ProductDefinition.supplier_id == definition.supplier_id, - ProductDefinition.product_code == definition.product_code - ) - ) - existing = result.scalar_one_or_none() - if existing: - raise HTTPException( - status_code=400, - detail="Product definition already exists for this product code and supplier" - ) - - prod_def = ProductDefinition( - kitchen_id=current_user.kitchen_id, - supplier_id=definition.supplier_id, - product_code=definition.product_code, - description_pattern=definition.description_pattern, - pack_quantity=definition.pack_quantity, - unit_size=Decimal(str(definition.unit_size)) if definition.unit_size else None, - unit_size_type=definition.unit_size_type, - portions_per_unit=definition.portions_per_unit, - portion_description=definition.portion_description, - saved_by_user_id=current_user.id # Record who created this definition - ) - db.add(prod_def) - await db.commit() - await db.refresh(prod_def) - - return ProductDefinitionResponse( - id=prod_def.id, - kitchen_id=prod_def.kitchen_id, - supplier_id=prod_def.supplier_id, - product_code=prod_def.product_code, - description_pattern=prod_def.description_pattern, - pack_quantity=prod_def.pack_quantity, - unit_size=float(prod_def.unit_size) if prod_def.unit_size else None, - unit_size_type=prod_def.unit_size_type, - portions_per_unit=prod_def.portions_per_unit, - portion_description=prod_def.portion_description, - saved_by_user_id=prod_def.saved_by_user_id, - saved_by_username=current_user.name, - source_invoice_id=prod_def.source_invoice_id, - source_invoice_number=prod_def.source_invoice_number, - updated_at=prod_def.updated_at.isoformat() if prod_def.updated_at else None - ) - - -@router.post("/{invoice_id}/apply-definitions") -async def apply_definitions_to_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Re-apply product definitions to all line items on an invoice. - Useful after manually setting/changing the supplier on an invoice. - Only updates line items where portions_per_unit is not already set. - """ - from sqlalchemy import or_ - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - if not invoice.supplier_id: - return {"message": "No supplier set on invoice - no definitions to apply", "updated": 0} - - # Get all line items for this invoice - result = await db.execute( - select(LineItem) - .where(LineItem.invoice_id == invoice_id) - .order_by(LineItem.line_number) - ) - line_items = result.scalars().all() - - if not line_items: - return {"message": "No line items on invoice", "updated": 0} - - # Get definitions for this supplier (or kitchen-wide) - conditions = [ - ProductDefinition.kitchen_id == current_user.kitchen_id, - or_( - ProductDefinition.supplier_id == invoice.supplier_id, - ProductDefinition.supplier_id.is_(None) - ) - ] - - result = await db.execute( - select(ProductDefinition).where(*conditions) - ) - all_definitions = result.scalars().all() - - logger.info(f"apply_definitions_to_invoice: invoice_id={invoice_id}, supplier_id={invoice.supplier_id}, found {len(all_definitions)} definitions") - - # Build lookup dicts - prefer supplier-specific over kitchen-wide - definitions_by_code = {} - definitions_by_desc = [] # List of (normalized_pattern, definition) tuples - - for d in all_definitions: - if d.product_code: - if d.product_code in definitions_by_code: - existing = definitions_by_code[d.product_code] - if existing.supplier_id and not d.supplier_id: - continue - definitions_by_code[d.product_code] = d - if d.description_pattern: - norm_pattern = normalize_description(d.description_pattern) - if norm_pattern: - definitions_by_desc.append((norm_pattern, d)) - - # Sort description patterns: prefer supplier-specific first, then by length - definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0]))) - - if not definitions_by_code and not definitions_by_desc: - return {"message": "No product definitions found for this supplier", "updated": 0} - - logger.info(f"apply_definitions_to_invoice: {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions") - - def find_definition_for_item(item: LineItem) -> ProductDefinition | None: - if item.product_code and item.product_code in definitions_by_code: - return definitions_by_code[item.product_code] - if item.description: - item_desc_norm = normalize_description(item.description) - for pattern, defn in definitions_by_desc: - if pattern in item_desc_norm: - return defn - return None - - updated_count = 0 - for item in line_items: - defn = find_definition_for_item(item) - if not defn: - continue - - match_type = "product_code" if item.product_code and item.product_code in definitions_by_code else "description" - logger.info(f"apply_definitions_to_invoice: applying definition (matched by {match_type}) to item: code={item.product_code}, desc={item.description[:50] if item.description else ''}") - - # Only update if portions_per_unit is not already set - if item.portions_per_unit is None and defn.portions_per_unit: - item.portions_per_unit = defn.portions_per_unit - - # Recalculate cost_per_portion - if item.pack_quantity and item.unit_price: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4) - )) - updated_count += 1 - - # Also apply pack_quantity if OCR didn't find it - if item.pack_quantity is None and defn.pack_quantity: - item.pack_quantity = defn.pack_quantity - if item.unit_price: - item.cost_per_item = Decimal(str( - round(float(item.unit_price) / defn.pack_quantity, 4) - )) - # Recalculate cost_per_portion if portions now available - if item.portions_per_unit and item.unit_price: - item.cost_per_portion = Decimal(str( - round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4) - )) - updated_count += 1 - # Apply unit_size and unit_size_type if definition has them - if item.unit_size is None and defn.unit_size: - item.unit_size = defn.unit_size - if item.unit_size_type is None and defn.unit_size_type: - item.unit_size_type = defn.unit_size_type - - await db.commit() - - return {"message": f"Applied definitions to {updated_count} line items", "updated": updated_count} - - -class SaveDefinitionRequest(BaseModel): - portion_description: Optional[str] = None - - -@router.post("/{invoice_id}/line-items/{item_id}/save-definition", response_model=ProductDefinitionResponse) -async def save_line_item_as_definition( - invoice_id: int, - item_id: int, - request: Optional[SaveDefinitionRequest] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Save a line item's pack/portion data as a product definition. - Creates or updates the definition for future invoices. - - Matching priority when saving: - - If line item has product_code: save with product_code (preferred) - - If no product_code but has description: save with description_pattern - """ - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - result = await db.execute( - select(LineItem).where( - LineItem.id == item_id, - LineItem.invoice_id == invoice_id - ) - ) - line_item = result.scalar_one_or_none() - if not line_item: - raise HTTPException(status_code=404, detail="Line item not found") - - # Need either product_code or description to save a definition - if not line_item.product_code and not line_item.description: - raise HTTPException( - status_code=400, - detail="Cannot save definition: line item has no product code or description" - ) - - existing = None - - # Check if definition already exists - by product_code if available, otherwise by description - if line_item.product_code: - result = await db.execute( - select(ProductDefinition).where( - ProductDefinition.kitchen_id == current_user.kitchen_id, - ProductDefinition.supplier_id == invoice.supplier_id, - ProductDefinition.product_code == line_item.product_code - ) - ) - existing = result.scalar_one_or_none() - elif line_item.description: - # Match by normalized description pattern - norm_desc = normalize_description(line_item.description) - result = await db.execute( - select(ProductDefinition).where( - ProductDefinition.kitchen_id == current_user.kitchen_id, - ProductDefinition.supplier_id == invoice.supplier_id, - ProductDefinition.product_code.is_(None) # Only match description-based definitions - ) - ) - all_desc_defs = result.scalars().all() - for d in all_desc_defs: - if d.description_pattern and normalize_description(d.description_pattern) == norm_desc: - existing = d - break - - # Get portion_description from request if provided - portion_desc = request.portion_description if request else None - - if existing: - # Update existing definition - existing.pack_quantity = line_item.pack_quantity - existing.unit_size = line_item.unit_size - existing.unit_size_type = line_item.unit_size_type - existing.portions_per_unit = line_item.portions_per_unit - existing.description_pattern = line_item.description - if portion_desc is not None: - existing.portion_description = portion_desc - # Update saved by metadata - existing.saved_by_user_id = current_user.id - existing.source_invoice_id = invoice.id - await db.commit() - await db.refresh(existing) - prod_def = existing - else: - # Create new definition - prod_def = ProductDefinition( - kitchen_id=current_user.kitchen_id, - supplier_id=invoice.supplier_id, - product_code=line_item.product_code, # May be None for description-only definitions - description_pattern=line_item.description, - pack_quantity=line_item.pack_quantity, - unit_size=line_item.unit_size, - unit_size_type=line_item.unit_size_type, - portions_per_unit=line_item.portions_per_unit, - portion_description=portion_desc, - # Saved by metadata - saved_by_user_id=current_user.id, - source_invoice_id=invoice.id - ) - db.add(prod_def) - await db.commit() - await db.refresh(prod_def) - - return ProductDefinitionResponse( - id=prod_def.id, - kitchen_id=prod_def.kitchen_id, - supplier_id=prod_def.supplier_id, - product_code=prod_def.product_code, - description_pattern=prod_def.description_pattern, - pack_quantity=prod_def.pack_quantity, - unit_size=float(prod_def.unit_size) if prod_def.unit_size else None, - unit_size_type=prod_def.unit_size_type, - portions_per_unit=prod_def.portions_per_unit, - portion_description=prod_def.portion_description, - saved_by_user_id=prod_def.saved_by_user_id, - saved_by_username=current_user.name, # We have the user in scope - source_invoice_id=prod_def.source_invoice_id, - source_invoice_number=invoice.invoice_number, # Get from current invoice - updated_at=prod_def.updated_at.isoformat() if prod_def.updated_at else None - ) - - -@router.get("/{invoice_id}/line-items/{item_id}/definition", response_model=ProductDefinitionResponse | None) -async def get_line_item_definition( - invoice_id: int, - item_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get the saved product definition that applies to a specific line item. - Returns null if no definition exists. - Used by frontend to compare current values with saved values. - """ - from sqlalchemy import or_ - from sqlalchemy.orm import selectinload - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - result = await db.execute( - select(LineItem).where( - LineItem.id == item_id, - LineItem.invoice_id == invoice_id - ) - ) - line_item = result.scalar_one_or_none() - if not line_item: - raise HTTPException(status_code=404, detail="Line item not found") - - # Get definitions for this supplier (or kitchen-wide) - conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id] - if invoice.supplier_id: - conditions.append( - or_( - ProductDefinition.supplier_id == invoice.supplier_id, - ProductDefinition.supplier_id.is_(None) - ) - ) - else: - conditions.append(ProductDefinition.supplier_id.is_(None)) - - result = await db.execute( - select(ProductDefinition).options( - selectinload(ProductDefinition.saved_by_user), - selectinload(ProductDefinition.source_invoice) - ).where(*conditions) - ) - all_definitions = result.scalars().all() - - # Build lookup dicts - prefer supplier-specific over kitchen-wide - definitions_by_code = {} - definitions_by_desc = [] - - for d in all_definitions: - if d.product_code: - if d.product_code in definitions_by_code: - existing = definitions_by_code[d.product_code] - if existing.supplier_id and not d.supplier_id: - continue - definitions_by_code[d.product_code] = d - if d.description_pattern: - norm_pattern = normalize_description(d.description_pattern) - if norm_pattern: - definitions_by_desc.append((norm_pattern, d)) - - definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0]))) - - # Find matching definition - defn = None - if line_item.product_code and line_item.product_code in definitions_by_code: - defn = definitions_by_code[line_item.product_code] - elif line_item.description: - item_desc_norm = normalize_description(line_item.description) - for pattern, d in definitions_by_desc: - if pattern in item_desc_norm: - defn = d - break - - if not defn: - return None - - return ProductDefinitionResponse( - id=defn.id, - kitchen_id=defn.kitchen_id, - supplier_id=defn.supplier_id, - product_code=defn.product_code, - description_pattern=defn.description_pattern, - pack_quantity=defn.pack_quantity, - unit_size=float(defn.unit_size) if defn.unit_size else None, - unit_size_type=defn.unit_size_type, - portions_per_unit=defn.portions_per_unit, - portion_description=defn.portion_description, - saved_by_user_id=defn.saved_by_user_id, - saved_by_username=defn.saved_by_user.name if defn.saved_by_user else None, - source_invoice_id=defn.source_invoice_id, - source_invoice_number=defn.source_invoice.invoice_number if defn.source_invoice else None, - updated_at=defn.updated_at.isoformat() if defn.updated_at else None - ) - - -# ============ Stock History Endpoint ============ - -@router.get("/{invoice_id}/stock-history") -async def get_invoice_stock_history( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get stock status history for all line items in an invoice. - - Returns a mapping of line_item_id to stock history info, - indicating if items were previously marked as non-stock. - """ - from services.stock_history import StockHistoryService - - invoice = await get_invoice_or_404(invoice_id, current_user, db) - - stock_service = StockHistoryService(db, current_user.kitchen_id) - history_map = await stock_service.check_all_line_items(invoice_id) - - # Convert to JSON-serializable format - result = {} - for item_id, history in history_map.items(): - result[str(item_id)] = { - 'has_history': history.has_history, - 'previously_non_stock': history.previously_non_stock, - 'total_occurrences': history.total_occurrences, - 'non_stock_occurrences': history.non_stock_occurrences, - 'most_recent_status': history.most_recent_status - } - - return result - - -# ============ Dext Integration Endpoint ============ - -@router.post("/{invoice_id}/send-to-dext") -async def send_invoice_to_dext( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Send invoice to Dext via email - - - Loads invoice with line items - - Generates HTML email with notes and non-stock items (if configured) - - Attaches invoice PDF/image - - Sends via SMTP - - Records sent timestamp and user - """ - from datetime import datetime - from sqlalchemy.orm import selectinload - from models.settings import KitchenSettings - from services.email_service import EmailService, generate_dext_email_html - - # Load invoice with relationships - result = await db.execute( - select(Invoice).options( - selectinload(Invoice.supplier), - selectinload(Invoice.line_items) - ).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Must be confirmed to send - if invoice.status != InvoiceStatus.CONFIRMED: - raise HTTPException( - status_code=400, - detail="Invoice must be confirmed before sending to Dext" - ) - - # Check if supplier has skip_dext enabled - if invoice.supplier and invoice.supplier.skip_dext: - raise HTTPException( - status_code=400, - detail=f"Supplier '{invoice.supplier.name}' is configured to skip Dext forwarding" - ) - - # Load settings - settings_result = await db.execute( - select(KitchenSettings).where( - KitchenSettings.kitchen_id == current_user.kitchen_id - ) - ) - settings = settings_result.scalar_one_or_none() - - if not settings: - raise HTTPException( - status_code=400, - detail="Kitchen settings not found" - ) - - # Validate SMTP configuration - if not all([settings.smtp_host, settings.smtp_from_email]): - raise HTTPException( - status_code=400, - detail="SMTP not configured. Please configure email settings first." - ) - - # Validate Dext configuration - if not settings.dext_email: - raise HTTPException( - status_code=400, - detail="Dext email not configured. Please configure Dext settings first." - ) - - # Check if file exists - if not os.path.exists(invoice.image_path): - raise HTTPException(status_code=404, detail="Invoice file not found") - - # Update PDF highlights for non-stock items (if annotations are enabled in settings) - # Highlights are annotations (overlays) - not burnt in - so can be updated anytime - if settings.dext_include_annotations: - try: - from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items - import json - - # Parse OCR JSON to get line items with bounding regions - ocr_data = json.loads(invoice.ocr_raw_json) if invoice.ocr_raw_json else {} - ocr_line_items = parse_azure_ocr_line_items(ocr_data) - - if ocr_line_items: - non_stock_items = [item for item in invoice.line_items if item.is_non_stock] - - # Create backup if doesn't exist (safety measure) - import shutil - backup_path = invoice.image_path + '.original' - if not os.path.exists(backup_path): - shutil.copy2(invoice.image_path, backup_path) - - # Regenerate highlights (clears existing, adds current non-stock items) - highlighter = PDFHighlighter(invoice.image_path) - highlighter.highlight_items_with_ocr_data( - ocr_line_items=ocr_line_items, - non_stock_line_items=non_stock_items, - output_path=invoice.image_path, - notes=invoice.notes, - ocr_data=ocr_data - ) - - if non_stock_items: - logger.info(f"Updated PDF with {len(non_stock_items)} highlighted non-stock items") - else: - logger.info("Cleared all highlights from PDF (no non-stock items)") - else: - logger.debug("No OCR line item data available for highlighting") - - except Exception as e: - logger.warning(f"PDF highlighting failed, using original: {e}") - else: - logger.info("PDF annotations disabled in settings, skipping highlights") - - # Read file (now contains highlights if successful) - try: - with open(invoice.image_path, 'rb') as f: - file_bytes = f.read() - except Exception as e: - logger.error(f"Failed to read invoice file: {e}") - raise HTTPException(status_code=500, detail="Failed to read invoice file") - - # Determine filename from invoice data - ext = invoice.image_path.split('.')[-1].lower() - filename = f"{invoice.invoice_number or 'invoice'}_{invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else 'unknown'}.{ext}" - - # Generate email HTML and plain text - from services.email_service import generate_dext_email_plain - html_body = generate_dext_email_html( - invoice=invoice, - supplier_name=invoice.supplier.name if invoice.supplier else None, - line_items=invoice.line_items, - notes=invoice.notes, - include_notes=settings.dext_include_notes, - include_non_stock=settings.dext_include_non_stock - ) - plain_body = generate_dext_email_plain( - invoice=invoice, - supplier_name=invoice.supplier.name if invoice.supplier else None, - line_items=invoice.line_items, - notes=invoice.notes, - include_notes=settings.dext_include_notes, - include_non_stock=settings.dext_include_non_stock - ) - - # Send email - email_service = EmailService(settings) - subject = f"Invoice {invoice.invoice_number or 'N/A'} - {invoice.supplier.name if invoice.supplier else 'Unknown Supplier'}" - - success = email_service.send_email( - to_email=settings.dext_email, - subject=subject, - html_body=html_body, - plain_body=plain_body, - attachments=[(filename, file_bytes)] - ) - - if not success: - raise HTTPException(status_code=500, detail="Failed to send email") - - # Record sent status - invoice.dext_sent_at = datetime.utcnow() - invoice.dext_sent_by_user_id = current_user.id - await db.commit() - - return { - "message": "Invoice sent to Dext successfully", - "sent_at": invoice.dext_sent_at.isoformat(), - "sent_to": settings.dext_email - } - - -# ============ PDF Highlighting Endpoint ============ - -@router.post("/{invoice_id}/regenerate-highlights") -async def regenerate_highlights( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Regenerate PDF highlights for non-stock items. - - Clears any existing highlights and adds new ones for current non-stock items. - Useful for testing or manually triggering highlight updates. - """ - from sqlalchemy.orm import selectinload - from models.settings import KitchenSettings - import json - - # Check if PDF annotations are enabled in settings - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - if not settings or not settings.pdf_annotations_enabled: - raise HTTPException( - status_code=400, - detail="PDF annotations are disabled in settings. Enable them first to regenerate highlights." - ) - - # Load invoice with line items - result = await db.execute( - select(Invoice).options( - selectinload(Invoice.line_items) - ).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Check file exists - if not os.path.exists(invoice.image_path): - raise HTTPException(status_code=404, detail="Invoice file not found") - - # Check OCR data exists - if not invoice.ocr_raw_json: - raise HTTPException(status_code=400, detail="No OCR data available for this invoice") - - try: - from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items - - # Parse OCR JSON using Azure format parser - ocr_data = json.loads(invoice.ocr_raw_json) - ocr_line_items = parse_azure_ocr_line_items(ocr_data) - - if not ocr_line_items: - raise HTTPException(status_code=400, detail="No line items with bounding regions in OCR data") - - # Get non-stock items - non_stock_items = [item for item in invoice.line_items if item.is_non_stock] - - import shutil - - # Create backup of original if it doesn't exist (safety measure) - original_backup = invoice.image_path + '.original' - if not os.path.exists(original_backup): - shutil.copy2(invoice.image_path, original_backup) - logger.info(f"Created original backup: {original_backup}") - else: - # Restore from backup first to clear any burnt-in content - shutil.copy2(original_backup, invoice.image_path) - logger.info(f"Restored from original backup before regenerating highlights") - - # Regenerate highlights - highlighter = PDFHighlighter(invoice.image_path) - result_path = highlighter.highlight_items_with_ocr_data( - ocr_line_items=ocr_line_items, - non_stock_line_items=non_stock_items, - output_path=invoice.image_path, - notes=invoice.notes, - ocr_data=ocr_data - ) - - return { - "message": "Highlights regenerated successfully", - "non_stock_count": len(non_stock_items), - "ocr_line_items_count": len(ocr_line_items), - "has_notes": bool(invoice.notes), - "pdf_path": result_path - } - - except ImportError as e: - logger.error(f"PyMuPDF not installed: {e}") - raise HTTPException(status_code=500, detail="PDF highlighting library not installed. Run: pip install PyMuPDF") - except Exception as e: - logger.error(f"Highlight regeneration failed: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to regenerate highlights: {str(e)}") - - -# ============ Admin Manual Control Endpoints ============ - -@router.post("/{invoice_id}/mark-dext-sent") -async def mark_dext_sent( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Admin only: Mark invoice as sent to Dext without actually sending. - Also triggers Nextcloud archival if configured. - - For edge cases where invoice was uploaded directly to Dext. - """ - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - from datetime import datetime - from sqlalchemy.orm import selectinload - - # Load invoice - result = await db.execute( - select(Invoice).options( - selectinload(Invoice.supplier) - ).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Check if supplier has skip_dext enabled - if invoice.supplier and invoice.supplier.skip_dext: - raise HTTPException( - status_code=400, - detail=f"Supplier '{invoice.supplier.name}' is configured to skip Dext forwarding" - ) - - # Mark as sent - invoice.dext_sent_at = datetime.utcnow() - invoice.dext_sent_by_user_id = current_user.id - await db.commit() - await db.refresh(invoice) - - # Try to archive to Nextcloud if enabled - archival_message = None - try: - from services.file_archival_service import FileArchivalService - archival_service = FileArchivalService(db, current_user.kitchen_id) - - if await archival_service.is_ready_for_archival(invoice): - success, result_msg = await archival_service.archive_invoice_file(invoice) - if success: - archival_message = f"Archived to Nextcloud: {result_msg}" - await db.commit() - else: - archival_message = f"Archival skipped: {result_msg}" - else: - archival_message = "Not ready for archival (Nextcloud may not be enabled or configured)" - except Exception as e: - logger.error(f"Archival failed after marking Dext sent: {e}") - archival_message = f"Archival error: {str(e)}" - - return { - "message": "Invoice marked as sent to Dext", - "sent_at": invoice.dext_sent_at.isoformat(), - "archival_status": archival_message - } - - -@router.post("/bulk/mark-all-dext-sent") -async def mark_all_dext_sent( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Admin only: Mark all invoices not yet sent to Dext as sent without actually sending. - - Useful for migrating to the system or for invoices sent outside the system. - """ - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - from datetime import datetime - from sqlalchemy import update - - now = datetime.utcnow() - - # Update all invoices where dext_sent_at is null - result = await db.execute( - update(Invoice) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.dext_sent_at.is_(None) - ) - .values( - dext_sent_at=now, - dext_sent_by_user_id=current_user.id - ) - ) - - updated_count = result.rowcount - await db.commit() - - logger.info(f"Bulk marked {updated_count} invoices as sent to Dext for kitchen {current_user.kitchen_id}") - - return { - "message": f"Marked {updated_count} invoice(s) as sent to Dext", - "count": updated_count, - "marked_at": now.isoformat() - } - - -@router.post("/{invoice_id}/reprocess") -async def reprocess_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Admin only: Reprocess existing OCR data without re-sending to Azure. - - Re-runs: - - Supplier identification - - Document type detection - - Line item creation (with product definitions) - - Duplicate detection - """ - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - # Load invoice - result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Check if we have OCR data - if not invoice.ocr_raw_json: - raise HTTPException( - status_code=400, - detail="No OCR data available. Use 'Resend to Azure' instead." - ) - - try: - import json - import re - from ocr.parser import identify_supplier - from services.duplicate_detector import detect_document_type, DuplicateDetector - - # Load kitchen settings for post-processing options - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - # Parse stored OCR data - raw_json = json.loads(invoice.ocr_raw_json) - - # Re-identify supplier - supplier_id = None - supplier_match_type = None - if invoice.vendor_name: - supplier_id, supplier_match_type = await identify_supplier(invoice.vendor_name, current_user.kitchen_id, db) - if not supplier_id and invoice.ocr_raw_text: - supplier_id, supplier_match_type = await identify_supplier(invoice.ocr_raw_text, current_user.kitchen_id, db) - - # Use document_type from stored raw_json if available (from azure_extractor) - # Otherwise re-detect it - document_type = raw_json.get("document_type") - if not document_type: - document_type = detect_document_type( - invoice.ocr_raw_text or "", - raw_json - ) - - # Update invoice fields - invoice.supplier_id = supplier_id - invoice.supplier_match_type = supplier_match_type - invoice.document_type = document_type - - # Delete existing line items - await db.execute( - text("DELETE FROM line_items WHERE invoice_id = :invoice_id"), - {"invoice_id": invoice_id} - ) - await db.flush() - - # Re-create line items from stored OCR data - # The stored raw_json is the Azure response, which has Items in Azure format - # We need to extract line_items like the Azure extractor does - line_items_data = [] - - # Check if we have the processed line_items (newer format) - if "line_items" in raw_json: - line_items_data = raw_json["line_items"] - # Otherwise parse from Azure raw format (older invoices) - elif "documents" in raw_json: - # Helper to extract numeric value from Azure field (handles currency objects) - def get_numeric_value(field_data): - if not field_data: - return None - value = field_data.get("value") - if value is None: - return None - # Currency fields have {"code": "GBP", "amount": 54.2, "symbol": null} - if isinstance(value, dict) and "amount" in value: - return value["amount"] - return value - - # Extract from Azure format - simplified extraction - for doc in raw_json.get("documents", []): - fields = doc.get("fields", {}) - if "Items" in fields: - items = fields["Items"].get("value", []) - for ocr_idx, item in enumerate(items): - item_fields = item.get("value", {}) - line_items_data.append({ - "ocr_index": ocr_idx, - "product_code": item_fields.get("ProductCode", {}).get("value"), - "description": item_fields.get("Description", {}).get("value"), - "unit": item_fields.get("Unit", {}).get("value"), - "quantity": get_numeric_value(item_fields.get("Quantity", {})), - "unit_price": get_numeric_value(item_fields.get("UnitPrice", {})), - "amount": get_numeric_value(item_fields.get("Amount", {})), - "tax_rate": get_numeric_value(item_fields.get("TaxRate", {})), - "raw_content": item.get("content"), # Raw text for weight extraction - }) - - # Apply product definitions - line_items_data = await apply_product_definitions( - line_items_data, current_user.kitchen_id, supplier_id, db - ) - - # Apply OCR post-processing settings - if settings: - processed_items = [] - for item in line_items_data: - # 1. Clean product codes (strip section headers like "CHILL/AMBIENT") - if settings.ocr_clean_product_codes and item.get("product_code"): - if '\n' in item["product_code"]: - item["product_code"] = item["product_code"].split('\n')[-1].strip() - - # 1b. Fallback: extract product code from raw_content if Azure missed it - ocr_warnings = list(item.get("ocr_warnings", "").split("; ")) if item.get("ocr_warnings") else [] - if not item.get("product_code") and item.get("raw_content"): - first_line = item["raw_content"].split('\n')[0].strip() - # Valid product code patterns: - # - All digits, 2-6 chars (e.g., "1646", "955") - # - Alphanumeric with optional hyphens, 2-15 chars (e.g., "01SAL4K06", "ABC-123") - # - Must NOT look like a price (no decimal point with 2 digits after) - is_numeric_code = re.match(r'^\d{2,6}$', first_line) - is_alphanum_code = re.match(r'^[A-Z0-9][A-Z0-9\-]{1,14}$', first_line, re.IGNORECASE) - is_price = re.match(r'^\d+\.\d{2}$', first_line) # e.g., "14.64" - - if (is_numeric_code or is_alphanum_code) and not is_price: - item["product_code"] = first_line - ocr_warnings.append(f"SKU extracted from raw content: {first_line}") - logger.info(f"Extracted product code from raw_content fallback: '{first_line}'") - - # Update ocr_warnings - if ocr_warnings: - item["ocr_warnings"] = "; ".join([w for w in ocr_warnings if w]) - - # 2. Filter subtotal rows - if settings.ocr_filter_subtotal_rows: - desc = (item.get("description") or "").lower() - has_total_keyword = "sub total" in desc or desc.endswith("total") - no_product_code = not item.get("product_code") - no_quantity = item.get("quantity") is None - if has_total_keyword and no_product_code and no_quantity: - continue # Skip subtotal rows - - # 3. Use weight as quantity for KG items - if settings.ocr_use_weight_as_quantity: - unit = (item.get("unit") or "").upper() - qty = item.get("quantity") - price = item.get("unit_price") - amount = item.get("amount") - - if unit == "KG" and qty is not None and price is not None and amount is not None: - expected = qty * price - if abs(expected - amount) > 0.02: # Mismatch detected - raw = item.get("raw_content") or "" - # Pattern: weight on its own line (after newline) with space before KG - # This avoids matching product sizes like "1.25-1.65KG" in descriptions - weight_match = re.search(r'\n(\d+\.\d+)\s+KG', raw, re.IGNORECASE) - # Fallback: try matching at start of content (if weight is first) - if not weight_match: - weight_match = re.search(r'^(\d+\.\d+)\s+KG', raw, re.IGNORECASE) - if weight_match: - weight = float(weight_match.group(1)) - weight_expected = weight * price - if abs(weight_expected - amount) <= 0.02: # Validated - item["order_quantity"] = qty - item["quantity"] = weight - - processed_items.append(item) - line_items_data = processed_items - - # Create line items - for idx, item_data in enumerate(line_items_data): - line_item = LineItem( - invoice_id=invoice.id, - product_code=item_data.get("product_code"), - description=item_data.get("description"), - description_alt=item_data.get("description_alt"), - unit=item_data.get("unit"), - quantity=Decimal(str(item_data["quantity"])) if item_data.get("quantity") else None, - order_quantity=Decimal(str(item_data["order_quantity"])) if item_data.get("order_quantity") else None, - unit_price=Decimal(str(item_data["unit_price"])) if item_data.get("unit_price") else None, - tax_rate=item_data.get("tax_rate"), - tax_amount=Decimal(str(item_data["tax_amount"])) if item_data.get("tax_amount") else None, - amount=Decimal(str(item_data["amount"])) if item_data.get("amount") else None, - line_number=item_data.get("ocr_index", idx), - raw_content=item_data.get("raw_content"), - pack_quantity=item_data.get("pack_quantity"), - unit_size=Decimal(str(item_data["unit_size"])) if item_data.get("unit_size") else None, - unit_size_type=item_data.get("unit_size_type"), - portions_per_unit=item_data.get("portions_per_unit"), - cost_per_item=Decimal(str(item_data["cost_per_item"])) if item_data.get("cost_per_item") else None, - cost_per_portion=Decimal(str(item_data["cost_per_portion"])) if item_data.get("cost_per_portion") else None, - ocr_warnings=item_data.get("ocr_warnings") - ) - db.add(line_item) - - await db.flush() - - # Auto-normalize: fill missing product codes + rename alias descriptions - if supplier_id: - try: - from api.ingredients import auto_normalize_line_items - norm_count = await auto_normalize_line_items(invoice.id, current_user.kitchen_id, supplier_id, db) - if norm_count: - logger.info(f"Auto-normalized {norm_count} line items on remapped invoice {invoice_id}") - except Exception as e: - logger.warning(f"Auto-normalize failed on remap (non-critical): {e}") - - # Re-run duplicate detection - detector = DuplicateDetector(db, current_user.kitchen_id) - duplicates = await detector.check_duplicates(invoice) - - if duplicates["firm_duplicate"]: - invoice.duplicate_status = "firm_duplicate" - invoice.duplicate_of_id = duplicates["firm_duplicate"].id - elif duplicates["possible_duplicates"]: - invoice.duplicate_status = "possible_duplicate" - invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id - else: - invoice.duplicate_status = None - invoice.duplicate_of_id = None - - if duplicates["related_documents"]: - invoice.related_document_id = duplicates["related_documents"][0].id - - await db.commit() - - logger.info(f"Invoice {invoice_id} reprocessed by admin {current_user.id}") - - return { - "message": "Invoice reprocessed successfully", - "supplier_id": supplier_id, - "document_type": document_type, - "line_items_count": len(line_items_data), - "duplicate_status": invoice.duplicate_status - } - - except Exception as e: - await db.rollback() - logger.error(f"Reprocessing failed for invoice {invoice_id}: {e}") - raise HTTPException(status_code=500, detail=f"Reprocessing failed: {str(e)}") - - -@router.post("/{invoice_id}/resend-to-azure") -async def resend_to_azure( - invoice_id: int, - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Admin only: Re-send invoice to Azure for OCR extraction. - - Fully re-processes the invoice: - - Re-extracts from Azure Document Intelligence - - Updates all invoice fields - - Re-creates line items (with product definitions) - - Re-runs duplicate detection - """ - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - # Load invoice - result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Check if file exists - if not os.path.exists(invoice.image_path): - raise HTTPException(status_code=404, detail="Invoice file not found") - - # Reset status to processing - invoice.status = InvoiceStatus.PENDING - await db.commit() - - # Run background processing - background_tasks.add_task( - process_invoice_background, - invoice_id, - invoice.image_path, - current_user.kitchen_id - ) - - logger.info(f"Invoice {invoice_id} re-sent to Azure by admin {current_user.id}") - - return { - "message": "Invoice re-sent to Azure for processing", - "status": "pending" - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.post("/{invoice_id}/ai-assist") -async def ai_assist_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Run AI analysis on an invoice to suggest corrections and enhancements. - Returns suggestions for: supplier matching, line item corrections, - description recommendations, subtotal detection, total mismatch analysis. - """ - from services.llm_service import assist_invoice_ocr, reconcile_line_items - - # Load invoice - result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Load line items - li_result = await db.execute( - select(LineItem).where(LineItem.invoice_id == invoice_id).order_by(LineItem.line_number) - ) - line_items_db = li_result.scalars().all() - - # Build data for LLM - invoice_data = { - "invoice_number": invoice.invoice_number, - "invoice_date": str(invoice.invoice_date) if invoice.invoice_date else None, - "total": str(invoice.total) if invoice.total else None, - "net_total": str(invoice.net_total) if invoice.net_total else None, - "vendor_name": invoice.vendor_name, - "raw_text": invoice.ocr_raw_text[:3000] if invoice.ocr_raw_text else "", - } - - items_for_llm = [ - { - "idx": i, - "product_code": li.product_code, - "description": li.description, - "description_alt": li.description_alt, - "quantity": float(li.quantity) if li.quantity else None, - "unit_price": float(li.unit_price) if li.unit_price else None, - "amount": float(li.amount) if li.amount else None, - "raw_content": li.raw_content, - "ocr_warnings": li.ocr_warnings, - } - for i, li in enumerate(line_items_db) - ] - - # Load suppliers for matching - from models.supplier import Supplier - sup_result = await db.execute( - select(Supplier.id, Supplier.name).where( - Supplier.kitchen_id == current_user.kitchen_id, - ) - ) - supplier_list = [{"id": s.id, "name": s.name} for s in sup_result.all()] - - # Run OCR assist - ocr_result = await assist_invoice_ocr( - db=db, - kitchen_id=current_user.kitchen_id, - invoice_data=invoice_data, - line_items=items_for_llm, - supplier_list=supplier_list, - ) - - # Run line item reconciliation for unmatched items - reconciliation_result = {"status": "unavailable", "matches": None, "error": None} - if invoice.supplier_id: - unmatched = [ - {"idx": i, "description": li.description, "product_code": li.product_code} - for i, li in enumerate(line_items_db) - if not li.ingredient_id and li.description - ] - - if unmatched: - # Get supplier history: past line items mapped to ingredients (last 90 days) - from datetime import timedelta - cutoff = date.today() - timedelta(days=90) - history_result = await db.execute( - select( - LineItem.description, - LineItem.ingredient_id, - ).select_from(LineItem).join( - Invoice, LineItem.invoice_id == Invoice.id - ).where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.supplier_id == invoice.supplier_id, - Invoice.invoice_date >= cutoff, - LineItem.ingredient_id.isnot(None), - LineItem.description.isnot(None), - ).distinct() - ) - history_rows = history_result.all() - - if history_rows: - # Resolve ingredient names - ingredient_ids = list({r.ingredient_id for r in history_rows}) - from models.ingredient import Ingredient - ing_result = await db.execute( - select(Ingredient.id, Ingredient.name).where(Ingredient.id.in_(ingredient_ids)) - ) - ing_names = {r.id: r.name for r in ing_result.all()} - - supplier_history = [ - { - "description": r.description, - "ingredient_id": r.ingredient_id, - "ingredient_name": ing_names.get(r.ingredient_id, "Unknown"), - } - for r in history_rows - if r.ingredient_id in ing_names - ] - - reconciliation_result = await reconcile_line_items( - db=db, - kitchen_id=current_user.kitchen_id, - unmatched_items=unmatched, - supplier_history=supplier_history, - ) - - return { - "llm_status": ocr_result["status"], - "suggestions": ocr_result.get("suggestions"), - "reconciliation_status": reconciliation_result["status"], - "reconciliation_matches": reconciliation_result.get("matches"), - "error": ocr_result.get("error") or reconciliation_result.get("error"), - } - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.get("/{invoice_id}/line-items/{item_id}/ai-pack-size") -async def ai_deduce_pack_size( - invoice_id: int, - item_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Deduce pack size for a line item using regex first, then LLM fallback. - Returns pack_quantity, unit_size, unit_size_type. - """ - # Load line item - result = await db.execute( - select(LineItem).join(Invoice).where( - LineItem.id == item_id, - LineItem.invoice_id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id, - ) - ) - line_item = result.scalar_one_or_none() - if not line_item: - raise HTTPException(status_code=404, detail="Line item not found") - - # Tier 1: Regex parse (free, instant) - parsed = parse_pack_size(line_item.raw_content or line_item.description or "") - if parsed["pack_quantity"]: - return { - "source": "regex", - "pack_quantity": parsed["pack_quantity"], - "unit_size": parsed["unit_size"], - "unit_size_type": parsed["unit_size_type"], - "reason": "Parsed from product description", - } - - # Tier 2: Infer from invoice unit field - unit_str = (line_item.unit or "").strip().lower() - std_unit = UNIT_FIELD_MAP.get(unit_str) - if std_unit and line_item.quantity: - return { - "source": "unit_field", - "pack_quantity": 1, - "unit_size": float(line_item.quantity), - "unit_size_type": std_unit, - "reason": f"Inferred from invoice unit field ({line_item.unit})", - } - - # Tier 3: LLM deduction (cached, uses product knowledge) - from services.llm_service import deduce_pack_size - llm_result = await deduce_pack_size( - db=db, - kitchen_id=current_user.kitchen_id, - description=line_item.description or "", - raw_content=line_item.raw_content, - unit=line_item.unit, - ) - - if llm_result["status"] in ("success", "cached") and llm_result["pack_quantity"]: - return { - "source": "ai", - "pack_quantity": llm_result["pack_quantity"], - "unit_size": llm_result["unit_size"], - "unit_size_type": llm_result["unit_size_type"], - "reason": llm_result.get("reason", "AI deduction"), - } - - return { - "source": None, - "pack_quantity": None, - "unit_size": None, - "unit_size_type": None, - "reason": llm_result.get("error") or "Could not determine pack size", - } diff --git a/backend/api/logbook.py b/backend/api/logbook.py deleted file mode 100644 index 3acac85..0000000 --- a/backend/api/logbook.py +++ /dev/null @@ -1,745 +0,0 @@ -""" -Logbook API for wastage, transfers, staff food, and manual adjustments. -""" -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, or_, func -from sqlalchemy.orm import selectinload -from datetime import date, datetime -from typing import Optional, List -from pydantic import BaseModel -from decimal import Decimal -import os -import logging - -from auth import get_current_user, require_cap -from database import get_db -from models.user import User -from models.logbook import ( - LogbookEntry, LogbookLineItem, LogbookAttachment, - EntryType, WastageReason, TransferStatus -) -# from models.products import Product # TODO: Add Product model - -router = APIRouter(prefix="/logbook", tags=["Logbook"]) -logger = logging.getLogger(__name__) - - -# ============ Pydantic Schemas ============ - -class LineItemInput(BaseModel): - product_id: Optional[int] = None - product_name: str - product_code: Optional[str] = None - supplier_name: Optional[str] = None - quantity: float - unit: Optional[str] = None - unit_price: Optional[float] = None - total_cost: float - notes: Optional[str] = None - - -class WastageEntryInput(BaseModel): - entry_date: date - reason: WastageReason - line_items: List[LineItemInput] - notes: Optional[str] = None - reference_number: Optional[str] = None - - -class TransferEntryInput(BaseModel): - entry_date: date - destination_kitchen_id: int - status: TransferStatus = TransferStatus.PENDING - line_items: List[LineItemInput] - notes: Optional[str] = None - reference_number: Optional[str] = None - - -class StaffFoodEntryInput(BaseModel): - entry_date: date - meal_type: str # breakfast, lunch, dinner, snack - staff_count: Optional[int] = None - line_items: List[LineItemInput] - notes: Optional[str] = None - - -class ManualAdjustmentInput(BaseModel): - entry_date: date - adjustment_reason: str - original_invoice_id: Optional[int] = None - line_items: List[LineItemInput] - notes: Optional[str] = None - reference_number: Optional[str] = None - - -class LineItemResponse(BaseModel): - id: int - product_id: Optional[int] - product_name: str - product_code: Optional[str] - supplier_name: Optional[str] - quantity: float - unit: Optional[str] - unit_price: Optional[float] - total_cost: float - notes: Optional[str] - - -class AttachmentResponse(BaseModel): - id: int - file_name: str - file_path: str - file_type: str - file_size_bytes: int - description: Optional[str] - uploaded_at: str - - -class LogbookEntryResponse(BaseModel): - id: int - entry_type: str - entry_date: str - reference_number: Optional[str] - total_cost: float - notes: Optional[str] - type_data: dict - created_by: int - created_by_name: Optional[str] - created_at: str - line_items: List[LineItemResponse] - attachments: List[AttachmentResponse] - - -class LogbookSummary(BaseModel): - total_entries: int - total_cost: float - by_type: dict - - -# ============ Helper Functions ============ - -def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse: - """Convert LogbookEntry model to response""" - return LogbookEntryResponse( - id=entry.id, - entry_type=entry.entry_type.value, - entry_date=entry.entry_date.isoformat(), - reference_number=entry.reference_number, - total_cost=float(entry.total_cost), - notes=entry.notes, - type_data=entry.type_data or {}, - created_by=entry.created_by, - created_by_name=entry.created_by_user.name if entry.created_by_user else None, - created_at=entry.created_at.isoformat(), - line_items=[ - LineItemResponse( - id=item.id, - product_id=item.product_id, - product_name=item.product_name, - product_code=item.product_code, - supplier_name=item.supplier_name, - quantity=float(item.quantity), - unit=item.unit, - unit_price=float(item.unit_price) if item.unit_price else None, - total_cost=float(item.total_cost), - notes=item.notes - ) - for item in entry.line_items - ], - attachments=[ - AttachmentResponse( - id=att.id, - file_name=att.file_name, - file_path=att.file_path, - file_type=att.file_type, - file_size_bytes=att.file_size_bytes, - description=att.description, - uploaded_at=att.uploaded_at.isoformat() - ) - for att in entry.attachments - ] - ) - - -async def create_line_items( - db: AsyncSession, - entry_id: int, - kitchen_id: int, - items: List[LineItemInput] -) -> Decimal: - """Create line items for an entry and return total cost""" - total_cost = Decimal(0) - - for item_input in items: - line_item = LogbookLineItem( - entry_id=entry_id, - kitchen_id=kitchen_id, - product_id=item_input.product_id, - product_name=item_input.product_name, - product_code=item_input.product_code, - supplier_name=item_input.supplier_name, - quantity=Decimal(str(item_input.quantity)), - unit=item_input.unit, - unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None, - total_cost=Decimal(str(item_input.total_cost)), - notes=item_input.notes - ) - db.add(line_item) - total_cost += line_item.total_cost - - return total_cost - - -# ============ Endpoints ============ - -@router.get("") -async def get_logbook_entries( - entry_type: Optional[EntryType] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - search: Optional[str] = None, - limit: int = 100, - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> List[LogbookEntryResponse]: - """Get logbook entries with filters""" - - query = select(LogbookEntry).options( - selectinload(LogbookEntry.line_items), - selectinload(LogbookEntry.attachments), - selectinload(LogbookEntry.created_by_user) - ).where( - and_( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ) - - if entry_type: - query = query.where(LogbookEntry.entry_type == entry_type) - - if date_from: - query = query.where(LogbookEntry.entry_date >= date_from) - - if date_to: - query = query.where(LogbookEntry.entry_date <= date_to) - - if search: - # Search in notes, reference number, and line item product names - query = query.outerjoin(LogbookLineItem).where( - or_( - LogbookEntry.notes.ilike(f"%{search}%"), - LogbookEntry.reference_number.ilike(f"%{search}%"), - LogbookLineItem.product_name.ilike(f"%{search}%") - ) - ).distinct() - - query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc()) - query = query.limit(limit).offset(offset) - - try: - result = await db.execute(query) - entries = result.scalars().unique().all() - return [build_entry_response(entry) for entry in entries] - except Exception as e: - logger.exception(f"Error fetching logbook entries: {e}") - raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}") - - -@router.get("/summary") -async def get_logbook_summary( - date_from: Optional[date] = None, - date_to: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookSummary: - """Get summary statistics for logbook entries""" - - query = select( - LogbookEntry.entry_type, - func.count(LogbookEntry.id).label('count'), - func.sum(LogbookEntry.total_cost).label('total') - ).where( - and_( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ).group_by(LogbookEntry.entry_type) - - if date_from: - query = query.where(LogbookEntry.entry_date >= date_from) - - if date_to: - query = query.where(LogbookEntry.entry_date <= date_to) - - result = await db.execute(query) - rows = result.all() - - by_type = {} - total_entries = 0 - total_cost = Decimal(0) - - for row in rows: - entry_type, count, cost = row - by_type[entry_type.value] = { - 'count': count, - 'total_cost': float(cost or 0) - } - total_entries += count - total_cost += cost or 0 - - return LogbookSummary( - total_entries=total_entries, - total_cost=float(total_cost), - by_type=by_type - ) - - -@router.get("/daily-stats") -async def get_daily_logbook_stats( - date_from: Optional[date] = None, - date_to: Optional[date] = None, - entry_type: Optional[EntryType] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily logbook statistics for a date range (for purchases chart integration)""" - - query = select( - LogbookEntry.entry_date, - LogbookEntry.entry_type, - func.count(LogbookEntry.id).label("count"), - func.sum(LogbookEntry.total_cost).label("total_cost") - ).where( - and_( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ) - - if date_from: - query = query.where(LogbookEntry.entry_date >= date_from) - if date_to: - query = query.where(LogbookEntry.entry_date <= date_to) - if entry_type: - query = query.where(LogbookEntry.entry_type == entry_type) - - query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type) - query = query.order_by(LogbookEntry.entry_date) - - result = await db.execute(query) - rows = result.all() - - # Group by date, then by type - daily_stats = {} - for row in rows: - date_str = row.entry_date.isoformat() - if date_str not in daily_stats: - daily_stats[date_str] = {} - daily_stats[date_str][row.entry_type.value] = { - "count": row.count, - "total_cost": float(row.total_cost or 0) - } - - return {"daily_stats": daily_stats} - - -@router.get("/products/search") -async def search_products( - query: str, - limit: int = 20, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Search products from invoice line items for logbook entry""" - from models.line_item import LineItem - from models.invoice import Invoice - from models.supplier import Supplier - - # Search line items from invoices for this kitchen - # Get distinct products with most recent price - result = await db.execute( - select( - LineItem.description, - LineItem.product_code, - LineItem.unit, - LineItem.unit_price, - Supplier.name.label('supplier_name') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) - .where( - and_( - Invoice.kitchen_id == current_user.kitchen_id, - or_( - LineItem.description.ilike(f"%{query}%"), - LineItem.product_code.ilike(f"%{query}%") - ) - ) - ) - .order_by(Invoice.invoice_date.desc()) - .limit(limit * 3) # Get more to allow for deduplication - ) - rows = result.all() - - # Deduplicate by description, keeping first (most recent) price - seen = set() - products = [] - for row in rows: - key = (row.description or '').lower() - if key and key not in seen: - seen.add(key) - products.append({ - "id": 0, # No persistent product ID - "name": row.description, - "product_code": row.product_code, - "supplier_name": row.supplier_name, - "unit": row.unit, - "last_price": float(row.unit_price) if row.unit_price else None - }) - if len(products) >= limit: - break - - return products - - -@router.get("/{entry_id}") -async def get_logbook_entry( - entry_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookEntryResponse: - """Get single logbook entry with details""" - - result = await db.execute( - select(LogbookEntry).where( - and_( - LogbookEntry.id == entry_id, - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ) - ) - entry = result.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Entry not found") - - return build_entry_response(entry) - - -@router.post("/wastage") -async def create_wastage_entry( - entry_input: WastageEntryInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookEntryResponse: - """Create wastage entry""" - - if not entry_input.line_items: - raise HTTPException(status_code=400, detail="At least one line item is required") - - entry = LogbookEntry( - kitchen_id=current_user.kitchen_id, - entry_type=EntryType.WASTAGE, - entry_date=entry_input.entry_date, - reference_number=entry_input.reference_number, - notes=entry_input.notes, - type_data={"reason": entry_input.reason.value}, - created_by=current_user.id - ) - - db.add(entry) - await db.flush() # Get entry.id - - total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) - entry.total_cost = total_cost - - await db.commit() - await db.refresh(entry) - - logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}") - - return await get_logbook_entry(entry.id, current_user, db) - - -@router.post("/transfer") -async def create_transfer_entry( - entry_input: TransferEntryInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookEntryResponse: - """Create transfer entry""" - - if not entry_input.line_items: - raise HTTPException(status_code=400, detail="At least one line item is required") - - entry = LogbookEntry( - kitchen_id=current_user.kitchen_id, - entry_type=EntryType.TRANSFER, - entry_date=entry_input.entry_date, - reference_number=entry_input.reference_number, - notes=entry_input.notes, - type_data={ - "destination_kitchen_id": entry_input.destination_kitchen_id, - "status": entry_input.status.value - }, - created_by=current_user.id - ) - - db.add(entry) - await db.flush() - - total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) - entry.total_cost = total_cost - - await db.commit() - await db.refresh(entry) - - logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}") - - # TODO: Send notification to destination kitchen - - return await get_logbook_entry(entry.id, current_user, db) - - -@router.post("/staff-food") -async def create_staff_food_entry( - entry_input: StaffFoodEntryInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookEntryResponse: - """Create staff food entry""" - - if not entry_input.line_items: - raise HTTPException(status_code=400, detail="At least one line item is required") - - entry = LogbookEntry( - kitchen_id=current_user.kitchen_id, - entry_type=EntryType.STAFF_FOOD, - entry_date=entry_input.entry_date, - notes=entry_input.notes, - type_data={ - "meal_type": entry_input.meal_type, - "staff_count": entry_input.staff_count - }, - created_by=current_user.id - ) - - db.add(entry) - await db.flush() - - total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) - entry.total_cost = total_cost - - await db.commit() - await db.refresh(entry) - - logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}") - - return await get_logbook_entry(entry.id, current_user, db) - - -@router.post("/manual-adjustment") -async def create_manual_adjustment_entry( - entry_input: ManualAdjustmentInput, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> LogbookEntryResponse: - """Create manual adjustment entry""" - - if not entry_input.line_items: - raise HTTPException(status_code=400, detail="At least one line item is required") - - entry = LogbookEntry( - kitchen_id=current_user.kitchen_id, - entry_type=EntryType.MANUAL_ADJUSTMENT, - entry_date=entry_input.entry_date, - reference_number=entry_input.reference_number, - notes=entry_input.notes, - type_data={ - "adjustment_reason": entry_input.adjustment_reason, - "original_invoice_id": entry_input.original_invoice_id - }, - created_by=current_user.id - ) - - db.add(entry) - await db.flush() - - total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) - entry.total_cost = total_cost - - await db.commit() - await db.refresh(entry) - - logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}") - - return await get_logbook_entry(entry.id, current_user, db) - - -@router.patch("/{entry_id}") -async def update_logbook_entry( - entry_id: int, - notes: Optional[str] = None, - reference_number: Optional[str] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update logbook entry (limited fields)""" - - result = await db.execute( - select(LogbookEntry).where( - and_( - LogbookEntry.id == entry_id, - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ) - ) - entry = result.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Entry not found") - - if notes is not None: - entry.notes = notes - - if reference_number is not None: - entry.reference_number = reference_number - - entry.updated_at = datetime.utcnow() - await db.commit() - - return {"status": "updated"} - - -@router.delete("/{entry_id}") -async def delete_logbook_entry( - entry_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Soft delete logbook entry""" - - result = await db.execute( - select(LogbookEntry).where( - and_( - LogbookEntry.id == entry_id, - LogbookEntry.kitchen_id == current_user.kitchen_id - ) - ) - ) - entry = result.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Entry not found") - - entry.is_deleted = True - entry.updated_at = datetime.utcnow() - await db.commit() - - logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}") - - return {"status": "deleted"} - - -@router.post("/{entry_id}/attachments") -async def upload_attachment( - entry_id: int, - file: UploadFile = File(...), - description: Optional[str] = Form(None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Upload photo or document to logbook entry""" - - # Verify entry exists and belongs to user's kitchen - result = await db.execute( - select(LogbookEntry).where( - and_( - LogbookEntry.id == entry_id, - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.is_deleted == False - ) - ) - ) - entry = result.scalar_one_or_none() - if not entry: - raise HTTPException(status_code=404, detail="Entry not found") - - # Validate file type - allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"] - if file.content_type not in allowed_types: - raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {allowed_types}") - - # Save file - upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}" - os.makedirs(upload_dir, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg" - file_name = f"entry_{entry_id}_{timestamp}{file_extension}" - file_path = f"{upload_dir}/{file_name}" - - content = await file.read() - with open(file_path, "wb") as f: - f.write(content) - - # Create attachment record - attachment = LogbookAttachment( - entry_id=entry_id, - kitchen_id=current_user.kitchen_id, - file_name=file.filename or file_name, - file_path=file_path, - file_type=file.content_type, - file_size_bytes=len(content), - description=description, - uploaded_by=current_user.id - ) - - db.add(attachment) - await db.commit() - - logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}") - - return { - "id": attachment.id, - "file_name": attachment.file_name, - "file_path": attachment.file_path - } - - -@router.delete("/{entry_id}/attachments/{attachment_id}") -async def delete_attachment( - entry_id: int, - attachment_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete attachment from logbook entry""" - - result = await db.execute( - select(LogbookAttachment).where( - and_( - LogbookAttachment.id == attachment_id, - LogbookAttachment.entry_id == entry_id, - LogbookAttachment.kitchen_id == current_user.kitchen_id - ) - ) - ) - attachment = result.scalar_one_or_none() - - if not attachment: - raise HTTPException(status_code=404, detail="Attachment not found") - - # Delete file from disk - if os.path.exists(attachment.file_path): - os.remove(attachment.file_path) - - await db.delete(attachment) - await db.commit() - - return {"status": "deleted"} diff --git a/backend/api/menus.py b/backend/api/menus.py deleted file mode 100644 index 0af6049..0000000 --- a/backend/api/menus.py +++ /dev/null @@ -1,1308 +0,0 @@ -""" -Menus API — CRUD for menus, divisions, items + publish/republish + image upload + duplication. -Prefix: /api/menus/ -""" -import os -import uuid -import logging -from datetime import datetime -from decimal import Decimal -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File -from fastapi.responses import FileResponse -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, delete -from sqlalchemy.orm import selectinload - -from database import get_db -from auth import get_current_user, require_cap -from models.user import User -from models.menu import Menu, MenuDivision, MenuItem -from models.recipe import Recipe, RecipeSubRecipe -from api.food_flags import compute_recipe_flags, _collect_recipe_ingredient_ids -from models.food_flag import FoodFlagCategory, FoodFlag -from models.ingredient import IngredientFlag, IngredientFlagNone - -logger = logging.getLogger(__name__) -DATA_DIR = os.getenv("DATA_DIR", "/app/data") - -router = APIRouter() - -DEFAULT_DIVISIONS = ["Starters", "Mains", "Sides", "Desserts"] - - -# ── Pydantic Models ────────────────────────────────────────────────────────── - -class MenuCreate(BaseModel): - name: str - description: Optional[str] = None - notes: Optional[str] = None - preset_divisions: bool = False - -class MenuUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - notes: Optional[str] = None - is_active: Optional[bool] = None - -class DivisionCreate(BaseModel): - name: str - -class DivisionUpdate(BaseModel): - name: str - -class ItemPublish(BaseModel): - recipe_id: int - division_id: int - display_name: str - description: Optional[str] = None - price: Optional[Decimal] = None - confirmed_by_name: str - -class ItemUpdate(BaseModel): - display_name: Optional[str] = None - description: Optional[str] = None - price: Optional[Decimal] = None - division_id: Optional[int] = None - -class RepublishRequest(BaseModel): - confirmed_by_name: str - -class BulkPublishItem(BaseModel): - recipe_id: int - display_name: str - description: Optional[str] = None - price: Optional[Decimal] = None - -class BulkPublishRequest(BaseModel): - division_id: int - confirmed_by_name: str - items: list[BulkPublishItem] - -class BatchRepublishItem(BaseModel): - id: int - confirmed: bool = True - -class BatchRepublishRequest(BaseModel): - confirmed_by_name: str - items: list[BatchRepublishItem] - -class MenuDuplicate(BaseModel): - name: str - -class ReorderRequest(BaseModel): - ids: list[int] - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -async def _get_menu(menu_id: int, kitchen_id: int, db: AsyncSession) -> Menu: - result = await db.execute( - select(Menu).where(Menu.id == menu_id, Menu.kitchen_id == kitchen_id) - ) - menu = result.scalar_one_or_none() - if not menu: - raise HTTPException(404, "Menu not found") - return menu - - -async def _collect_sub_recipe_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]: - """Recursively collect all sub-recipe IDs in a recipe tree.""" - if depth > 5: - return [] - ids = [recipe_id] - sr_result = await db.execute( - select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id) - ) - for (child_id,) in sr_result.fetchall(): - ids.extend(await _collect_sub_recipe_ids(child_id, db, depth + 1)) - return ids - - -async def _compute_staleness(items: list[MenuItem], db: AsyncSession) -> dict[int, dict]: - """Batch compute staleness for menu items. Returns {item_id: {is_stale, stale_reason, is_archived}}.""" - result = {} - recipe_ids = [item.recipe_id for item in items if item.recipe_id is not None] - - if not recipe_ids: - for item in items: - result[item.id] = { - "is_stale": False, - "stale_reason": None, - "is_archived": item.recipe_id is None, - } - return result - - # Batch load recipe updated_at and is_archived - recipe_result = await db.execute( - select(Recipe.id, Recipe.updated_at, Recipe.is_archived).where(Recipe.id.in_(recipe_ids)) - ) - recipe_info = {r.id: (r.updated_at, r.is_archived) for r in recipe_result.fetchall()} - - # Collect all sub-recipe trees for all menu item recipes - all_tree_ids: dict[int, list[int]] = {} - for rid in recipe_ids: - all_tree_ids[rid] = await _collect_sub_recipe_ids(rid, db) - - # Batch query max updated_at for all sub-recipes - all_sub_ids = set() - for tree in all_tree_ids.values(): - all_sub_ids.update(tree) - - sub_updated = {} - if all_sub_ids: - sub_result = await db.execute( - select(Recipe.id, Recipe.updated_at).where(Recipe.id.in_(list(all_sub_ids))) - ) - sub_updated = {r.id: r.updated_at for r in sub_result.fetchall()} - - for item in items: - if item.recipe_id is None: - result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True} - continue - - info = recipe_info.get(item.recipe_id) - if not info: - # Recipe was fully deleted (shouldn't happen with SET NULL but handle it) - result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True} - continue - - recipe_updated, recipe_archived = info - - if recipe_archived: - result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True} - continue - - # Check staleness: recipe or any sub-recipe updated after publish - is_stale = False - stale_reason = None - - if recipe_updated and item.published_at and recipe_updated > item.published_at: - is_stale = True - stale_reason = "Dish edited after publishing" - - if not is_stale: - tree_ids = all_tree_ids.get(item.recipe_id, []) - for sub_id in tree_ids: - if sub_id == item.recipe_id: - continue - sub_up = sub_updated.get(sub_id) - if sub_up and item.published_at and sub_up > item.published_at: - is_stale = True - stale_reason = "Sub-recipe edited after publishing" - break - - result[item.id] = {"is_stale": is_stale, "stale_reason": stale_reason, "is_archived": False} - - return result - - -async def _build_snapshot(item: MenuItem, flags: list, user_id: int, confirmed_by_name: str) -> dict: - """Build the snapshot JSON blob for a menu item at publish time.""" - return { - "display_name": item.display_name, - "description": item.description, - "price": str(item.price) if item.price is not None else None, - "confirmed_flags": [ - { - "id": f.food_flag_id, - "name": f.flag_name, - "code": f.flag_code, - "icon": f.flag_icon, - "category": f.category_name, - "propagation": f.propagation_type, - "excludable": f.excludable_on_request, - } - for f in flags if f.is_active - ], - "confirmed_by_name": confirmed_by_name, - "confirmed_by_user_id": user_id, - "published_at": datetime.utcnow().isoformat(), - } - - -async def _check_unassessed(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[dict]: - """Check for unassessed ingredients in a recipe. Returns list of unassessed if any.""" - all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db) - unique_ids = list(set(all_ing_ids)) - if not unique_ids: - return [] - - req_cat_result = await db.execute( - select(FoodFlagCategory.id, FoodFlagCategory.name).where( - FoodFlagCategory.kitchen_id == kitchen_id, - FoodFlagCategory.required == True, - ) - ) - required_cats = req_cat_result.all() - if not required_cats: - return [] - - cat_flag_map: dict[int, set[int]] = {} - for cat_id, _ in required_cats: - rf_result = await db.execute(select(FoodFlag.id).where(FoodFlag.category_id == cat_id)) - cat_flag_map[cat_id] = set(rf_result.scalars().all()) - - from models.ingredient import Ingredient - unassessed = [] - for ing_id in unique_ids: - ing_result = await db.execute(select(Ingredient.name).where(Ingredient.id == ing_id)) - name = ing_result.scalar() - if not name: - continue - - none_result = await db.execute( - select(IngredientFlagNone.category_id).where(IngredientFlagNone.ingredient_id == ing_id) - ) - none_cat_ids = set(none_result.scalars().all()) - - for cat_id, cat_name in required_cats: - if cat_id in none_cat_ids: - continue - flag_ids = cat_flag_map.get(cat_id, set()) - if not flag_ids: - continue - flag_count = await db.execute( - select(func.count(IngredientFlag.id)).where( - IngredientFlag.ingredient_id == ing_id, - IngredientFlag.food_flag_id.in_(flag_ids), - ) - ) - if flag_count.scalar() == 0: - unassessed.append({"id": ing_id, "name": name, "category": cat_name}) - break - - return unassessed - - -# ── Menu CRUD ──────────────────────────────────────────────────────────────── - -@router.get("") -async def list_menus( - search: Optional[str] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """List menus with division/item counts.""" - query = ( - select(Menu) - .options(selectinload(Menu.divisions), selectinload(Menu.items)) - .where(Menu.kitchen_id == user.kitchen_id) - .order_by(Menu.sort_order, Menu.name) - ) - result = await db.execute(query) - menus = result.scalars().all() - - if search: - search_lower = search.lower() - menus = [m for m in menus if search_lower in m.name.lower()] - - items_list = [] - for m in menus: - # Compute staleness for this menu's items - all_items = list(m.items) if m.items else [] - staleness = await _compute_staleness(all_items, db) if all_items else {} - stale_count = sum(1 for s in staleness.values() if s.get("is_stale")) - - items_list.append({ - "id": m.id, - "name": m.name, - "description": m.description, - "notes": m.notes, - "is_active": m.is_active, - "sort_order": m.sort_order, - "division_count": len(m.divisions) if m.divisions else 0, - "item_count": len(all_items), - "stale_count": stale_count, - "created_at": m.created_at.isoformat() if m.created_at else None, - "updated_at": m.updated_at.isoformat() if m.updated_at else None, - }) - - return items_list - - -@router.post("") -async def create_menu( - body: MenuCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Create a new menu, optionally with preset divisions.""" - # Check unique name - existing = await db.execute( - select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "A menu with this name already exists") - - # Get next sort_order - max_order = await db.execute( - select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id) - ) - next_order = (max_order.scalar() or 0) + 1 - - menu = Menu( - kitchen_id=user.kitchen_id, - name=body.name, - description=body.description, - notes=body.notes, - sort_order=next_order, - ) - db.add(menu) - await db.flush() - - if body.preset_divisions: - for i, name in enumerate(DEFAULT_DIVISIONS): - db.add(MenuDivision(menu_id=menu.id, name=name, sort_order=i)) - - await db.commit() - await db.refresh(menu) - - return { - "id": menu.id, - "name": menu.name, - "description": menu.description, - "notes": menu.notes, - "is_active": menu.is_active, - "sort_order": menu.sort_order, - } - - -@router.get("/{menu_id}") -async def get_menu( - menu_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Full menu detail with divisions, items, staleness, and archived indicators.""" - result = await db.execute( - select(Menu) - .options( - selectinload(Menu.divisions).selectinload(MenuDivision.items), - selectinload(Menu.items), - ) - .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id) - ) - menu = result.scalar_one_or_none() - if not menu: - raise HTTPException(404, "Menu not found") - - # Compute staleness for all items - all_items = menu.items or [] - staleness = await _compute_staleness(all_items, db) - - # Build divisions with items - divisions_data = [] - for div in sorted(menu.divisions or [], key=lambda d: d.sort_order): - div_items = sorted( - [i for i in all_items if i.division_id == div.id], - key=lambda i: i.sort_order, - ) - items_data = [] - for item in div_items: - stale_info = staleness.get(item.id, {"is_stale": False, "stale_reason": None, "is_archived": False}) - items_data.append({ - "id": item.id, - "recipe_id": item.recipe_id, - "display_name": item.display_name, - "description": item.description, - "price": str(item.price) if item.price is not None else None, - "sort_order": item.sort_order, - "snapshot_json": item.snapshot_json, - "confirmed_by_name": item.confirmed_by_name, - "confirmed_by_user_id": item.confirmed_by_user_id, - "published_at": item.published_at.isoformat() if item.published_at else None, - "has_image": bool(item.image_path), - "is_stale": stale_info["is_stale"], - "stale_reason": stale_info["stale_reason"], - "is_archived": stale_info["is_archived"], - }) - divisions_data.append({ - "id": div.id, - "name": div.name, - "sort_order": div.sort_order, - "items": items_data, - }) - - return { - "id": menu.id, - "name": menu.name, - "description": menu.description, - "notes": menu.notes, - "is_active": menu.is_active, - "sort_order": menu.sort_order, - "created_at": menu.created_at.isoformat() if menu.created_at else None, - "updated_at": menu.updated_at.isoformat() if menu.updated_at else None, - "divisions": divisions_data, - } - - -@router.put("/{menu_id}") -async def update_menu( - menu_id: int, - body: MenuUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Update menu name, description, notes, or active status.""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - if body.name is not None and body.name != menu.name: - existing = await db.execute( - select(Menu).where( - Menu.kitchen_id == user.kitchen_id, - Menu.name == body.name, - Menu.id != menu_id, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "A menu with this name already exists") - menu.name = body.name - - if body.description is not None: - menu.description = body.description - if body.notes is not None: - menu.notes = body.notes - if body.is_active is not None: - menu.is_active = body.is_active - - menu.updated_at = datetime.utcnow() - await db.commit() - return {"ok": True} - - -@router.delete("/{menu_id}") -async def delete_menu( - menu_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Delete a menu and all its divisions/items.""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - # Clean up menu item images from disk - items_result = await db.execute( - select(MenuItem.image_path).where(MenuItem.menu_id == menu_id, MenuItem.image_path != None) - ) - for (path,) in items_result.fetchall(): - if path and os.path.exists(path): - try: - os.remove(path) - except OSError: - pass - - await db.delete(menu) - await db.commit() - return {"ok": True} - - -@router.patch("/reorder") -async def reorder_menus( - body: ReorderRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Reorder menus.""" - for i, mid in enumerate(body.ids): - result = await db.execute( - select(Menu).where(Menu.id == mid, Menu.kitchen_id == user.kitchen_id) - ) - m = result.scalar_one_or_none() - if m: - m.sort_order = i - await db.commit() - return {"ok": True} - - -# ── Division CRUD ──────────────────────────────────────────────────────────── - -@router.post("/{menu_id}/divisions") -async def add_division( - menu_id: int, - body: DivisionCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Add a division to a menu.""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - existing = await db.execute( - select(MenuDivision).where(MenuDivision.menu_id == menu_id, MenuDivision.name == body.name) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "A division with this name already exists in this menu") - - max_order = await db.execute( - select(func.max(MenuDivision.sort_order)).where(MenuDivision.menu_id == menu_id) - ) - next_order = (max_order.scalar() or 0) + 1 - - div = MenuDivision(menu_id=menu_id, name=body.name, sort_order=next_order) - db.add(div) - await db.commit() - await db.refresh(div) - - return {"id": div.id, "name": div.name, "sort_order": div.sort_order} - - -@router.put("/{menu_id}/divisions/{division_id}") -async def update_division( - menu_id: int, - division_id: int, - body: DivisionUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Rename a division.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id) - ) - div = result.scalar_one_or_none() - if not div: - raise HTTPException(404, "Division not found") - - # Check unique name - existing = await db.execute( - select(MenuDivision).where( - MenuDivision.menu_id == menu_id, - MenuDivision.name == body.name, - MenuDivision.id != division_id, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "A division with this name already exists in this menu") - - div.name = body.name - await db.commit() - return {"ok": True} - - -@router.delete("/{menu_id}/divisions/{division_id}") -async def delete_division( - menu_id: int, - division_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Delete a division and its items.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id) - ) - div = result.scalar_one_or_none() - if not div: - raise HTTPException(404, "Division not found") - - # Clean up item images - items_result = await db.execute( - select(MenuItem.image_path).where(MenuItem.division_id == division_id, MenuItem.image_path != None) - ) - for (path,) in items_result.fetchall(): - if path and os.path.exists(path): - try: - os.remove(path) - except OSError: - pass - - await db.delete(div) - await db.commit() - return {"ok": True} - - -@router.patch("/{menu_id}/divisions/reorder") -async def reorder_divisions( - menu_id: int, - body: ReorderRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Reorder divisions within a menu.""" - await _get_menu(menu_id, user.kitchen_id, db) - - for i, div_id in enumerate(body.ids): - result = await db.execute( - select(MenuDivision).where(MenuDivision.id == div_id, MenuDivision.menu_id == menu_id) - ) - div = result.scalar_one_or_none() - if div: - div.sort_order = i - await db.commit() - return {"ok": True} - - -# ── Publish / Items ────────────────────────────────────────────────────────── - -@router.post("/{menu_id}/items") -async def publish_item( - menu_id: int, - body: ItemPublish, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Publish a dish to a menu with allergen confirmation.""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - # Verify recipe is a non-archived dish - recipe_result = await db.execute( - select(Recipe).where( - Recipe.id == body.recipe_id, - Recipe.kitchen_id == user.kitchen_id, - Recipe.recipe_type == "dish", - Recipe.is_archived == False, - ) - ) - recipe = recipe_result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Dish not found or is archived") - - # Check not already on this menu - existing = await db.execute( - select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == body.recipe_id) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "This dish is already on this menu") - - # Verify division belongs to this menu - div_result = await db.execute( - select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id) - ) - if not div_result.scalar_one_or_none(): - raise HTTPException(400, "Division not found in this menu") - - # Check for unassessed ingredients - unassessed = await _check_unassessed(body.recipe_id, user.kitchen_id, db) - if unassessed: - raise HTTPException(400, detail={ - "message": "Cannot publish: dish has unassessed ingredients", - "unassessed_ingredients": unassessed, - }) - - # Compute flags - flags = await compute_recipe_flags(body.recipe_id, user.kitchen_id, db) - - # Get next sort_order - max_order = await db.execute( - select(func.max(MenuItem.sort_order)).where( - MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id - ) - ) - next_order = (max_order.scalar() or 0) + 1 - - item = MenuItem( - menu_id=menu_id, - division_id=body.division_id, - recipe_id=body.recipe_id, - display_name=body.display_name, - description=body.description, - price=body.price, - sort_order=next_order, - confirmed_by_user_id=user.id, - confirmed_by_name=body.confirmed_by_name, - published_at=datetime.utcnow(), - ) - - # Build snapshot - item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name) - - db.add(item) - await db.commit() - await db.refresh(item) - - return { - "id": item.id, - "display_name": item.display_name, - "published_at": item.published_at.isoformat() if item.published_at else None, - } - - -@router.post("/{menu_id}/items/bulk") -async def bulk_publish_items( - menu_id: int, - body: BulkPublishRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Bulk publish multiple dishes to a menu.""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - # Verify division - div_result = await db.execute( - select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id) - ) - if not div_result.scalar_one_or_none(): - raise HTTPException(400, "Division not found in this menu") - - # Validate all dishes first - errors = [] - valid_items = [] - for bi in body.items: - recipe_result = await db.execute( - select(Recipe).where( - Recipe.id == bi.recipe_id, - Recipe.kitchen_id == user.kitchen_id, - Recipe.recipe_type == "dish", - Recipe.is_archived == False, - ) - ) - recipe = recipe_result.scalar_one_or_none() - if not recipe: - errors.append({"recipe_id": bi.recipe_id, "error": "Dish not found or is archived"}) - continue - - existing = await db.execute( - select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == bi.recipe_id) - ) - if existing.scalar_one_or_none(): - errors.append({"recipe_id": bi.recipe_id, "error": "Already on this menu"}) - continue - - unassessed = await _check_unassessed(bi.recipe_id, user.kitchen_id, db) - if unassessed: - errors.append({ - "recipe_id": bi.recipe_id, - "error": "Has unassessed ingredients", - "unassessed_ingredients": unassessed, - }) - continue - - valid_items.append((bi, recipe)) - - if errors: - raise HTTPException(400, detail={"message": "Some dishes failed validation", "errors": errors}) - - # All valid — publish - max_order = await db.execute( - select(func.max(MenuItem.sort_order)).where( - MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id - ) - ) - next_order = (max_order.scalar() or 0) + 1 - - created = [] - for i, (bi, recipe) in enumerate(valid_items): - flags = await compute_recipe_flags(bi.recipe_id, user.kitchen_id, db) - - item = MenuItem( - menu_id=menu_id, - division_id=body.division_id, - recipe_id=bi.recipe_id, - display_name=bi.display_name, - description=bi.description, - price=bi.price, - sort_order=next_order + i, - confirmed_by_user_id=user.id, - confirmed_by_name=body.confirmed_by_name, - published_at=datetime.utcnow(), - ) - item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name) - db.add(item) - created.append(bi.recipe_id) - - await db.commit() - return {"ok": True, "published_count": len(created), "published_recipe_ids": created} - - -@router.put("/{menu_id}/items/{item_id}") -async def update_item( - menu_id: int, - item_id: int, - body: ItemUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Edit a menu item's display info or move to a different division.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Menu item not found") - - if body.display_name is not None: - item.display_name = body.display_name - if body.description is not None: - item.description = body.description - if body.price is not None: - item.price = body.price - if body.division_id is not None: - div_result = await db.execute( - select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id) - ) - if not div_result.scalar_one_or_none(): - raise HTTPException(400, "Target division not found in this menu") - item.division_id = body.division_id - - # Update snapshot display fields - if item.snapshot_json: - snapshot = dict(item.snapshot_json) - if body.display_name is not None: - snapshot["display_name"] = body.display_name - if body.description is not None: - snapshot["description"] = body.description - if body.price is not None: - snapshot["price"] = str(body.price) - item.snapshot_json = snapshot - - await db.commit() - return {"ok": True} - - -@router.delete("/{menu_id}/items/{item_id}") -async def delete_item( - menu_id: int, - item_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Remove a dish from a menu.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Menu item not found") - - # Clean up image - if item.image_path and os.path.exists(item.image_path): - try: - os.remove(item.image_path) - except OSError: - pass - - await db.delete(item) - await db.commit() - return {"ok": True} - - -@router.post("/{menu_id}/items/{item_id}/republish") -async def republish_item( - menu_id: int, - item_id: int, - body: RepublishRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Republish a menu item — re-confirm allergens and update snapshot.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Menu item not found") - - if item.recipe_id is None: - raise HTTPException(400, "Cannot republish: dish has been archived or deleted") - - # Check unassessed - unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db) - if unassessed: - raise HTTPException(400, detail={ - "message": "Cannot republish: dish has unassessed ingredients", - "unassessed_ingredients": unassessed, - }) - - # Recompute flags - flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db) - - item.confirmed_by_user_id = user.id - item.confirmed_by_name = body.confirmed_by_name - item.published_at = datetime.utcnow() - item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name) - - await db.commit() - return {"ok": True, "published_at": item.published_at.isoformat()} - - -@router.patch("/{menu_id}/items/reorder") -async def reorder_items( - menu_id: int, - body: ReorderRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Reorder items within a menu.""" - await _get_menu(menu_id, user.kitchen_id, db) - - for i, item_id in enumerate(body.ids): - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if item: - item.sort_order = i - await db.commit() - return {"ok": True} - - -# ── Batch Republish ────────────────────────────────────────────────────────── - -@router.post("/{menu_id}/republish-stale") -async def batch_republish_stale( - menu_id: int, - body: BatchRepublishRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Batch republish stale items on a menu.""" - await _get_menu(menu_id, user.kitchen_id, db) - - results = [] - for bi in body.items: - if not bi.confirmed: - results.append({"id": bi.id, "status": "skipped"}) - continue - - result = await db.execute( - select(MenuItem).where(MenuItem.id == bi.id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - results.append({"id": bi.id, "status": "error", "message": "Item not found"}) - continue - - if item.recipe_id is None: - results.append({"id": bi.id, "status": "error", "message": "Dish archived"}) - continue - - unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db) - if unassessed: - results.append({ - "id": bi.id, "status": "blocked", - "message": "Has unassessed ingredients", - "unassessed_ingredients": unassessed, - }) - continue - - flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db) - item.confirmed_by_user_id = user.id - item.confirmed_by_name = body.confirmed_by_name - item.published_at = datetime.utcnow() - item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name) - results.append({"id": bi.id, "status": "republished"}) - - await db.commit() - return {"results": results} - - -# ── Menu Item Image ────────────────────────────────────────────────────────── - -@router.post("/{menu_id}/items/{item_id}/image") -async def upload_item_image( - menu_id: int, - item_id: int, - file: UploadFile = File(...), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Upload a customer-quality image for a menu item.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Menu item not found") - - # Remove old image if exists - if item.image_path and os.path.exists(item.image_path): - try: - os.remove(item.image_path) - except OSError: - pass - - # Save new image - img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "menus") - os.makedirs(img_dir, exist_ok=True) - - ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" - filename = f"{uuid.uuid4()}{ext}" - filepath = os.path.join(img_dir, filename) - - content = await file.read() - with open(filepath, "wb") as f: - f.write(content) - - item.image_path = filepath - item.uploaded_by = user.id - - # Update snapshot with image indicator - if item.snapshot_json: - snapshot = dict(item.snapshot_json) - snapshot["has_image"] = True - item.snapshot_json = snapshot - - await db.commit() - return {"ok": True} - - -@router.get("/{menu_id}/items/{item_id}/image") -async def serve_item_image( - menu_id: int, - item_id: int, - current_user=Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item or not item.image_path: - raise HTTPException(404, "Image not found") - - if not os.path.exists(item.image_path): - raise HTTPException(404, "Image file not found") - - return FileResponse(item.image_path) - - -@router.delete("/{menu_id}/items/{item_id}/image") -async def delete_item_image( - menu_id: int, - item_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Remove a menu item's image.""" - await _get_menu(menu_id, user.kitchen_id, db) - - result = await db.execute( - select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(404, "Menu item not found") - - if item.image_path and os.path.exists(item.image_path): - try: - os.remove(item.image_path) - except OSError: - pass - - item.image_path = None - item.uploaded_by = None - - if item.snapshot_json: - snapshot = dict(item.snapshot_json) - snapshot.pop("has_image", None) - item.snapshot_json = snapshot - - await db.commit() - return {"ok": True} - - -# ── Menu Duplication ───────────────────────────────────────────────────────── - -@router.post("/{menu_id}/duplicate") -async def duplicate_menu( - menu_id: int, - body: MenuDuplicate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Clone a menu with new name, copying divisions and items (not images).""" - menu = await _get_menu(menu_id, user.kitchen_id, db) - - # Check unique name - existing = await db.execute( - select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name) - ) - if existing.scalar_one_or_none(): - raise HTTPException(400, "A menu with this name already exists") - - # Load full menu - result = await db.execute( - select(Menu) - .options(selectinload(Menu.divisions).selectinload(MenuDivision.items)) - .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id) - ) - source = result.scalar_one_or_none() - - max_order = await db.execute( - select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id) - ) - next_order = (max_order.scalar() or 0) + 1 - - new_menu = Menu( - kitchen_id=user.kitchen_id, - name=body.name, - description=source.description, - notes=source.notes, - is_active=False, # Start inactive - sort_order=next_order, - ) - db.add(new_menu) - await db.flush() - - # Copy divisions and items - for div in sorted(source.divisions or [], key=lambda d: d.sort_order): - new_div = MenuDivision(menu_id=new_menu.id, name=div.name, sort_order=div.sort_order) - db.add(new_div) - await db.flush() - - for item in sorted(div.items or [], key=lambda i: i.sort_order): - new_item = MenuItem( - menu_id=new_menu.id, - division_id=new_div.id, - recipe_id=item.recipe_id, - display_name=item.display_name, - description=item.description, - price=item.price, - sort_order=item.sort_order, - snapshot_json=item.snapshot_json, - confirmed_by_user_id=item.confirmed_by_user_id, - confirmed_by_name=item.confirmed_by_name, - published_at=item.published_at, - # image_path not copied — new menu needs own images - ) - db.add(new_item) - - await db.commit() - return {"id": new_menu.id, "name": new_menu.name} - - -# ── Flag Matrix (for print) ───────────────────────────────────────────────── - -@router.get("/{menu_id}/flags") -async def get_menu_flag_matrix( - menu_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Consolidated flag matrix for all items on a menu (for print view).""" - result = await db.execute( - select(Menu) - .options( - selectinload(Menu.divisions).selectinload(MenuDivision.items), - selectinload(Menu.items), - ) - .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id) - ) - menu = result.scalar_one_or_none() - if not menu: - raise HTTPException(404, "Menu not found") - - # Get all flag categories and flags for this kitchen - cat_result = await db.execute( - select(FoodFlagCategory) - .options(selectinload(FoodFlagCategory.flags)) - .where(FoodFlagCategory.kitchen_id == user.kitchen_id) - .order_by(FoodFlagCategory.sort_order) - ) - categories = cat_result.scalars().all() - - all_flags = [] - for cat in categories: - for f in sorted(cat.flags, key=lambda x: x.sort_order): - all_flags.append({ - "id": f.id, - "name": f.name, - "code": f.code, - "icon": f.icon, - "category_id": cat.id, - "category_name": cat.name, - }) - - # Build matrix from snapshots - divisions_data = [] - for div in sorted(menu.divisions or [], key=lambda d: d.sort_order): - div_items = sorted( - [i for i in (menu.items or []) if i.division_id == div.id], - key=lambda i: i.sort_order, - ) - items_matrix = [] - for item in div_items: - snapshot_flags = {} - if item.snapshot_json and "confirmed_flags" in item.snapshot_json: - snapshot_flags = {f["id"]: True for f in item.snapshot_json["confirmed_flags"]} - - flag_cells = {} - for f in all_flags: - flag_cells[str(f["id"])] = snapshot_flags.get(f["id"], False) - - items_matrix.append({ - "id": item.id, - "display_name": item.display_name, - "flags": flag_cells, - }) - - divisions_data.append({ - "name": div.name, - "items": items_matrix, - }) - - return { - "menu_name": menu.name, - "all_flags": all_flags, - "divisions": divisions_data, - } - - -# ── Dish-on-menu lookup (for DishEditor/DishList indicators) ───────────────── - -@router.get("/dish/{recipe_id}/menus") -async def get_dish_menus( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Get list of menus a dish is published on.""" - result = await db.execute( - select(MenuItem.menu_id, Menu.name, Menu.is_active) - .join(Menu, MenuItem.menu_id == Menu.id) - .where( - MenuItem.recipe_id == recipe_id, - Menu.kitchen_id == user.kitchen_id, - ) - ) - rows = result.fetchall() - return [ - {"menu_id": r.menu_id, "menu_name": r.name, "is_active": r.is_active} - for r in rows - ] - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -class GenerateDescriptionRequest(BaseModel): - recipe_id: int - recipe_name: str - ingredients: list[str] = [] - allergen_flags: list[str] = [] - steps_summary: Optional[str] = None - - -@router.post("/generate-description") -async def generate_description( - body: GenerateDescriptionRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Generate a customer-facing menu description using AI.""" - from services.llm_service import generate_menu_description - - result = await generate_menu_description( - db=db, - kitchen_id=user.kitchen_id, - recipe_name=body.recipe_name, - ingredients=body.ingredients, - allergen_flags=body.allergen_flags, - steps_summary=body.steps_summary, - ) - - return { - "llm_status": result["status"], - "description": result.get("description"), - "error": result.get("error"), - } diff --git a/backend/api/newbook.py b/backend/api/newbook.py deleted file mode 100644 index 985b1e0..0000000 --- a/backend/api/newbook.py +++ /dev/null @@ -1,1016 +0,0 @@ -""" -Newbook API Endpoints - -Handles Newbook configuration, GL account management, and data sync operations. -""" -import logging -from datetime import date, datetime, timedelta - -logger = logging.getLogger(__name__) -from decimal import Decimal -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, delete -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from models.newbook import ( - NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory -) -from models.resos import ResosBooking, ResosOpeningHour -from auth import get_current_user, require_cap -from services.newbook_sync import NewbookSyncService -from services.newbook_api import NewbookAPIClient, NewbookAPIError - -router = APIRouter() - - -# ============ Pydantic Schemas ============ - -class NewbookSettingsResponse(BaseModel): - newbook_api_username: str | None - newbook_api_password_set: bool - newbook_api_key_set: bool - newbook_api_region: str | None - newbook_instance_id: str | None - newbook_last_sync: datetime | None - newbook_auto_sync_enabled: bool - newbook_upcoming_sync_enabled: bool - newbook_upcoming_sync_interval: int - newbook_last_upcoming_sync: datetime | None - newbook_breakfast_gl_codes: str | None - newbook_dinner_gl_codes: str | None - newbook_breakfast_vat_rate: Decimal | None - newbook_dinner_vat_rate: Decimal | None - - class Config: - from_attributes = True - - -class NewbookSettingsUpdate(BaseModel): - newbook_api_username: str | None = None - newbook_api_password: str | None = None - newbook_api_key: str | None = None - newbook_api_region: str | None = None # au, ap, eu, us - newbook_instance_id: str | None = None - newbook_auto_sync_enabled: bool | None = None - newbook_upcoming_sync_enabled: bool | None = None - newbook_upcoming_sync_interval: int | None = None - newbook_breakfast_gl_codes: str | None = None - newbook_dinner_gl_codes: str | None = None - newbook_breakfast_vat_rate: Decimal | None = None - newbook_dinner_vat_rate: Decimal | None = None - - -class GLAccountResponse(BaseModel): - id: int - gl_account_id: str - gl_code: str | None - gl_name: str - gl_type: str | None - gl_group_id: str | None - gl_group_name: str | None - is_tracked: bool - display_order: int - - class Config: - from_attributes = True - - -class GLAccountUpdate(BaseModel): - is_tracked: bool - display_order: int | None = None - - -class GLAccountBulkUpdateItem(BaseModel): - id: int - is_tracked: bool - display_order: int | None = None - - -class GLAccountBulkUpdate(BaseModel): - updates: list[GLAccountBulkUpdateItem] - - -class RoomCategoryResponse(BaseModel): - id: int - site_id: str - site_name: str - site_type: str | None - room_count: int = 0 - is_included: bool - display_order: int - - class Config: - from_attributes = True - - -class RoomCategoryUpdate(BaseModel): - is_included: bool - display_order: int | None = None - - -class RoomCategoryBulkUpdateItem(BaseModel): - id: int - is_included: bool - display_order: int | None = None - - -class RoomCategoryBulkUpdate(BaseModel): - updates: list[RoomCategoryBulkUpdateItem] - - -class DailyRevenueResponse(BaseModel): - date: date - gl_account_id: int - gl_account_name: str - amount_net: Decimal - - class Config: - from_attributes = True - - -class RevenueSummaryResponse(BaseModel): - start_date: date - end_date: date - total_revenue: Decimal - by_account: list[dict] # {gl_account_name, total} - by_date: list[dict] # {date, total} - - -class OccupancyResponse(BaseModel): - date: date - total_rooms: int | None - occupied_rooms: int | None - occupancy_percentage: Decimal | None - total_guests: int | None - breakfast_allocation_qty: int | None - breakfast_allocation_netvalue: Decimal | None - dinner_allocation_qty: int | None - dinner_allocation_netvalue: Decimal | None - is_forecast: bool - - class Config: - from_attributes = True - - -class SyncLogResponse(BaseModel): - id: int - sync_type: str - started_at: datetime - completed_at: datetime | None - status: str - records_fetched: int - error_message: str | None - date_from: date | None - date_to: date | None - - class Config: - from_attributes = True - - -class HistoricalSyncRequest(BaseModel): - date_from: date - date_to: date - - -class CalendarDayData(BaseModel): - date: date - has_data: bool - is_forecast: bool - # Occupancy - total_rooms: int | None = None - occupied_rooms: int | None = None - occupancy_percentage: Decimal | None = None - total_guests: int | None = None - # Meal allocations - breakfast_allocation_qty: int | None = None - breakfast_allocation_netvalue: Decimal | None = None - dinner_allocation_qty: int | None = None - dinner_allocation_netvalue: Decimal | None = None - # Revenue - total_revenue: Decimal | None = None - revenue_by_account: list[dict] | None = None # [{gl_name, amount}] - - -class CalendarDataResponse(BaseModel): - year: int - month: int - days: list[CalendarDayData] - - -# ============ Settings Endpoints ============ - -@router.get("/settings", response_model=NewbookSettingsResponse) -async def get_newbook_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get Newbook API settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - return NewbookSettingsResponse( - newbook_api_username=settings.newbook_api_username, - newbook_api_password_set=bool(settings.newbook_api_password), - newbook_api_key_set=bool(settings.newbook_api_key), - newbook_api_region=settings.newbook_api_region, - newbook_instance_id=settings.newbook_instance_id, - newbook_last_sync=settings.newbook_last_sync, - newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled, - newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled, - newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval, - newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync, - newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, - newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, - newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, - newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate - ) - - -@router.patch("/settings", response_model=NewbookSettingsResponse) -async def update_newbook_settings( - update: NewbookSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update Newbook API settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Validate region if provided - if update.newbook_api_region and update.newbook_api_region not in ["au", "ap", "eu", "us"]: - raise HTTPException(status_code=400, detail="Invalid region. Must be: au, ap, eu, us") - - # Update fields - update_data = update.model_dump(exclude_unset=True) - logger.info(f"Newbook settings update for kitchen {current_user.kitchen_id}: {update_data}") - - for field, value in update_data.items(): - if value is not None: - old_value = getattr(settings, field, None) - setattr(settings, field, value) - if field == 'newbook_upcoming_sync_enabled': - logger.info(f"Newbook upcoming sync enabled changed: {old_value} -> {value}") - - await db.commit() - await db.refresh(settings) - - logger.info(f"Newbook settings saved - upcoming_sync_enabled={settings.newbook_upcoming_sync_enabled}") - - return NewbookSettingsResponse( - newbook_api_username=settings.newbook_api_username, - newbook_api_password_set=bool(settings.newbook_api_password), - newbook_api_key_set=bool(settings.newbook_api_key), - newbook_api_region=settings.newbook_api_region, - newbook_instance_id=settings.newbook_instance_id, - newbook_last_sync=settings.newbook_last_sync, - newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled, - newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled, - newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval, - newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync, - newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, - newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, - newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, - newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate - ) - - -@router.get("/debug-upcoming-sync") -async def debug_upcoming_sync( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Debug endpoint to check upcoming sync setting in database""" - from sqlalchemy import text - - # Get value from SQLAlchemy model - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - # Also get raw value from database - raw_result = await db.execute( - text("SELECT newbook_upcoming_sync_enabled, newbook_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"), - {"kid": current_user.kitchen_id} - ) - raw_row = raw_result.fetchone() - - return { - "kitchen_id": current_user.kitchen_id, - "model_value": settings.newbook_upcoming_sync_enabled if settings else None, - "model_interval": settings.newbook_upcoming_sync_interval if settings else None, - "raw_db_enabled": raw_row[0] if raw_row else "column_not_found", - "raw_db_interval": raw_row[1] if raw_row else "column_not_found", - "last_sync": settings.newbook_last_upcoming_sync.isoformat() if settings and settings.newbook_last_upcoming_sync else None - } - - -@router.post("/test-connection") -async def test_newbook_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test Newbook API connection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.newbook_api_username, - settings.newbook_api_password, - settings.newbook_api_key, - settings.newbook_api_region - ]): - raise HTTPException(status_code=400, detail="Newbook credentials not fully configured") - - try: - async with NewbookAPIClient( - username=settings.newbook_api_username, - password=settings.newbook_api_password, - api_key=settings.newbook_api_key, - region=settings.newbook_api_region, - instance_id=settings.newbook_instance_id - ) as client: - success = await client.test_connection() - - if success: - return {"status": "success", "message": "Newbook connection successful"} - else: - raise HTTPException(status_code=400, detail="Connection test failed") - - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - -# ============ GL Account Endpoints ============ - -@router.get("/gl-accounts", response_model=list[GLAccountResponse]) -async def list_gl_accounts( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List all GL accounts for this kitchen, sorted by group then name""" - result = await db.execute( - select(NewbookGLAccount) - .where(NewbookGLAccount.kitchen_id == current_user.kitchen_id) - .order_by(NewbookGLAccount.gl_group_name, NewbookGLAccount.display_order, NewbookGLAccount.gl_name) - ) - return list(result.scalars().all()) - - -@router.post("/gl-accounts/fetch") -async def fetch_gl_accounts( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch/refresh GL accounts from Newbook API""" - try: - sync_service = NewbookSyncService(db, current_user.kitchen_id) - accounts = await sync_service.sync_gl_accounts() - - return { - "status": "success", - "message": f"Fetched {len(accounts)} GL accounts", - "count": len(accounts) - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - -@router.patch("/gl-accounts/bulk-update") -async def bulk_update_gl_accounts( - request: GLAccountBulkUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Bulk update GL account selections""" - updated = 0 - for upd in request.updates: - result = await db.execute( - select(NewbookGLAccount).where( - NewbookGLAccount.id == upd.id, - NewbookGLAccount.kitchen_id == current_user.kitchen_id - ) - ) - account = result.scalar_one_or_none() - if account: - account.is_tracked = upd.is_tracked - if upd.display_order is not None: - account.display_order = upd.display_order - updated += 1 - - await db.commit() - return {"status": "success", "updated": updated} - - -@router.patch("/gl-accounts/{account_id}", response_model=GLAccountResponse) -async def update_gl_account( - account_id: int, - update: GLAccountUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update GL account tracking settings""" - result = await db.execute( - select(NewbookGLAccount).where( - NewbookGLAccount.id == account_id, - NewbookGLAccount.kitchen_id == current_user.kitchen_id - ) - ) - account = result.scalar_one_or_none() - - if not account: - raise HTTPException(status_code=404, detail="GL account not found") - - account.is_tracked = update.is_tracked - if update.display_order is not None: - account.display_order = update.display_order - - await db.commit() - await db.refresh(account) - return account - - -# ============ Room Category Endpoints ============ - -@router.get("/room-categories", response_model=list[RoomCategoryResponse]) -async def get_room_categories( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get all room categories for the kitchen""" - result = await db.execute( - select(NewbookRoomCategory) - .where(NewbookRoomCategory.kitchen_id == current_user.kitchen_id) - .order_by(NewbookRoomCategory.display_order, NewbookRoomCategory.site_name) - ) - return list(result.scalars().all()) - - -@router.post("/room-categories/fetch") -async def fetch_room_categories( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch room categories from Newbook API and store in database""" - # Get settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.newbook_api_username: - raise HTTPException(status_code=400, detail="Newbook credentials not configured") - - try: - async with NewbookAPIClient( - username=settings.newbook_api_username, - password=settings.newbook_api_password, - api_key=settings.newbook_api_key, - region=settings.newbook_api_region or "au", - instance_id=settings.newbook_instance_id - ) as client: - categories = await client.get_site_list() - - # Delete all existing room categories for this kitchen (fresh replace) - await db.execute( - delete(NewbookRoomCategory).where( - NewbookRoomCategory.kitchen_id == current_user.kitchen_id - ) - ) - - # Insert fresh aggregated room types - for cat in categories: - new_cat = NewbookRoomCategory( - kitchen_id=current_user.kitchen_id, - site_id=cat["id"], - site_name=cat["name"], - site_type=cat.get("type"), - room_count=cat.get("count", 0), - is_included=True, # Default to included - ) - db.add(new_cat) - - await db.commit() - - return { - "status": "success", - "count": len(categories) - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - -@router.patch("/room-categories/bulk-update") -async def bulk_update_room_categories( - request: RoomCategoryBulkUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Bulk update room category selections""" - updated = 0 - for upd in request.updates: - result = await db.execute( - select(NewbookRoomCategory).where( - NewbookRoomCategory.id == upd.id, - NewbookRoomCategory.kitchen_id == current_user.kitchen_id - ) - ) - category = result.scalar_one_or_none() - if category: - category.is_included = upd.is_included - if upd.display_order is not None: - category.display_order = upd.display_order - updated += 1 - - await db.commit() - return {"status": "success", "updated": updated} - - -@router.patch("/room-categories/{category_id}", response_model=RoomCategoryResponse) -async def update_room_category( - category_id: int, - update: RoomCategoryUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update room category settings""" - result = await db.execute( - select(NewbookRoomCategory).where( - NewbookRoomCategory.id == category_id, - NewbookRoomCategory.kitchen_id == current_user.kitchen_id - ) - ) - category = result.scalar_one_or_none() - - if not category: - raise HTTPException(status_code=404, detail="Room category not found") - - category.is_included = update.is_included - if update.display_order is not None: - category.display_order = update.display_order - - await db.commit() - await db.refresh(category) - return category - - -# ============ Revenue Endpoints ============ - -@router.get("/revenue", response_model=list[DailyRevenueResponse]) -async def get_revenue( - start_date: date, - end_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily revenue data for a date range""" - result = await db.execute( - select(NewbookDailyRevenue, NewbookGLAccount.gl_name) - .join(NewbookGLAccount) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= start_date, - NewbookDailyRevenue.date <= end_date, - NewbookGLAccount.is_tracked == True - ) - .order_by(NewbookDailyRevenue.date, NewbookGLAccount.display_order) - ) - - revenue_data = [] - for rev, gl_name in result.all(): - revenue_data.append(DailyRevenueResponse( - date=rev.date, - gl_account_id=rev.gl_account_id, - gl_account_name=gl_name, - amount_net=rev.amount_net - )) - - return revenue_data - - -@router.get("/revenue/summary", response_model=RevenueSummaryResponse) -async def get_revenue_summary( - start_date: date, - end_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get aggregated revenue summary for a date range""" - # Total revenue - total_result = await db.execute( - select(func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= start_date, - NewbookDailyRevenue.date <= end_date, - NewbookGLAccount.is_tracked == True - ) - ) - total_revenue = total_result.scalar() or Decimal("0.00") - - # By account - by_account_result = await db.execute( - select(NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= start_date, - NewbookDailyRevenue.date <= end_date, - NewbookGLAccount.is_tracked == True - ) - .group_by(NewbookGLAccount.gl_name) - .order_by(func.sum(NewbookDailyRevenue.amount_net).desc()) - ) - by_account = [{"gl_account_name": name, "total": float(total)} for name, total in by_account_result.all()] - - # By date - by_date_result = await db.execute( - select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= start_date, - NewbookDailyRevenue.date <= end_date, - NewbookGLAccount.is_tracked == True - ) - .group_by(NewbookDailyRevenue.date) - .order_by(NewbookDailyRevenue.date) - ) - by_date = [{"date": d.isoformat(), "total": float(total)} for d, total in by_date_result.all()] - - return RevenueSummaryResponse( - start_date=start_date, - end_date=end_date, - total_revenue=total_revenue, - by_account=by_account, - by_date=by_date - ) - - -# ============ Occupancy Endpoints ============ - -@router.get("/occupancy", response_model=list[OccupancyResponse]) -async def get_occupancy( - start_date: date, - end_date: date, - include_forecast: bool = True, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get occupancy data for a date range""" - query = select(NewbookDailyOccupancy).where( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= start_date, - NewbookDailyOccupancy.date <= end_date - ) - - if not include_forecast: - query = query.where(NewbookDailyOccupancy.is_forecast == False) - - query = query.order_by(NewbookDailyOccupancy.date) - result = await db.execute(query) - - return list(result.scalars().all()) - - -# ============ Calendar Data Endpoint ============ - -@router.get("/calendar/{year}/{month}", response_model=CalendarDataResponse) -async def get_calendar_data( - year: int, - month: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get calendar view data for a specific month""" - import calendar - from datetime import date as date_type - from collections import defaultdict - - # Calculate month date range - _, last_day = calendar.monthrange(year, month) - month_start = date_type(year, month, 1) - month_end = date_type(year, month, last_day) - today = date_type.today() - - # Fetch occupancy data for the month - occupancy_result = await db.execute( - select(NewbookDailyOccupancy) - .where( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= month_start, - NewbookDailyOccupancy.date <= month_end - ) - ) - occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()} - - # Fetch revenue data for the month (grouped by date and GL account) - revenue_result = await db.execute( - select( - NewbookDailyRevenue.date, - NewbookGLAccount.gl_name, - NewbookDailyRevenue.amount_net - ) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= month_start, - NewbookDailyRevenue.date <= month_end, - NewbookGLAccount.is_tracked == True - ) - .order_by(NewbookDailyRevenue.date, NewbookGLAccount.gl_name) - ) - - # Group revenue by date - revenue_by_date = defaultdict(list) - for row_date, gl_name, amount in revenue_result.all(): - revenue_by_date[row_date].append({ - "gl_name": gl_name, - "amount": float(amount) - }) - - # Build calendar days - days = [] - for day in range(1, last_day + 1): - current_date = date_type(year, month, day) - occupancy = occupancy_by_date.get(current_date) - revenue_entries = revenue_by_date.get(current_date, []) - - has_data = occupancy is not None or len(revenue_entries) > 0 - - # Determine if forecast: today or future date, or marked as forecast in occupancy - # Today is considered "current" (updatable), not historical (locked) - is_forecast = current_date >= today - if occupancy and occupancy.is_forecast: - is_forecast = True - - # Calculate total revenue for the day - total_revenue = sum(entry["amount"] for entry in revenue_entries) if revenue_entries else None - - day_data = CalendarDayData( - date=current_date, - has_data=has_data, - is_forecast=is_forecast, - total_rooms=occupancy.total_rooms if occupancy else None, - occupied_rooms=occupancy.occupied_rooms if occupancy else None, - occupancy_percentage=occupancy.occupancy_percentage if occupancy else None, - total_guests=occupancy.total_guests if occupancy else None, - breakfast_allocation_qty=occupancy.breakfast_allocation_qty if occupancy else None, - breakfast_allocation_netvalue=occupancy.breakfast_allocation_netvalue if occupancy else None, - dinner_allocation_qty=occupancy.dinner_allocation_qty if occupancy else None, - dinner_allocation_netvalue=occupancy.dinner_allocation_netvalue if occupancy else None, - total_revenue=Decimal(str(total_revenue)) if total_revenue else None, - revenue_by_account=revenue_entries if revenue_entries else None - ) - days.append(day_data) - - return CalendarDataResponse( - year=year, - month=month, - days=days - ) - - -# ============ Sync Endpoints ============ - -@router.post("/sync/forecast") -async def sync_forecast_data( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Sync forecast period data (next ~2 months)""" - try: - sync_service = NewbookSyncService(db, current_user.kitchen_id) - results = await sync_service.sync_forecast_period() - - return { - "status": "success", - "message": "Forecast data synced", - "results": results - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - -@router.post("/sync/historical") -async def sync_historical_data( - request: HistoricalSyncRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Sync historical data for specific date range""" - if request.date_from > request.date_to: - raise HTTPException(status_code=400, detail="date_from must be before date_to") - - if (request.date_to - request.date_from).days > 365: - raise HTTPException(status_code=400, detail="Date range cannot exceed 1 year") - - try: - sync_service = NewbookSyncService(db, current_user.kitchen_id) - results = await sync_service.sync_historical_range(request.date_from, request.date_to) - - return { - "status": "success", - "message": f"Historical data synced for {request.date_from} to {request.date_to}", - "results": results - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - -@router.get("/sync/logs", response_model=list[SyncLogResponse]) -async def get_sync_logs( - limit: int = 20, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get recent sync logs""" - result = await db.execute( - select(NewbookSyncLog) - .where(NewbookSyncLog.kitchen_id == current_user.kitchen_id) - .order_by(NewbookSyncLog.started_at.desc()) - .limit(limit) - ) - return list(result.scalars().all()) - - -# ============ Dashboard Endpoints ============ - -class ArrivalDayStats(BaseModel): - date: date - day_name: str # "Today", "Tomorrow", day of week - arrival_count: int - arrival_guests: int - table_bookings: int - table_covers: int - matched_arrivals: int # Arrivals with table bookings - unmatched_arrivals: int # Arrivals without table bookings - opportunity_guests: int # Guests from unmatched arrivals - - -class ArrivalDashboardResponse(BaseModel): - days: list[ArrivalDayStats] - service_filter_name: str | None = None # Name of service type being filtered (e.g., "Dinner") - - -@router.get("/dashboard/arrivals", response_model=ArrivalDashboardResponse) -async def get_arrival_dashboard( - days: int = 3, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get arrival statistics for dashboard widget (next N days)""" - today = date.today() - end_date = today + timedelta(days=days - 1) - - # Fetch kitchen settings to get service filter - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - service_filter_type = settings.resos_arrival_widget_service_filter if settings else None - - # Get opening hour IDs that match the service type filter - service_filter_name = None - opening_hour_ids = [] - - if service_filter_type and settings and settings.resos_opening_hours_mapping: - # Find all opening hours mapped to this service type - for mapping in settings.resos_opening_hours_mapping: - if isinstance(mapping, dict) and mapping.get("service_type") == service_filter_type: - opening_hour_ids.append(mapping.get("resos_id")) - - # Set display name to capitalized service type - if opening_hour_ids: - service_filter_name = service_filter_type.capitalize() - - # Fetch occupancy data with arrival info - occupancy_result = await db.execute( - select(NewbookDailyOccupancy) - .where( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= today, - NewbookDailyOccupancy.date <= end_date - ) - .order_by(NewbookDailyOccupancy.date) - ) - occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()} - - # Fetch restaurant bookings for the same period (with optional service filter) - # Note: We fetch ALL bookings (not just guest-linked ones) to show total booking count - bookings_query = select(ResosBooking).where( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date >= today, - ResosBooking.booking_date <= end_date - ) - - # Apply service filter if set - filter by ANY opening hour that matches the service type - if opening_hour_ids: - bookings_query = bookings_query.where(ResosBooking.opening_hour_id.in_(opening_hour_ids)) - - bookings_result = await db.execute(bookings_query) - bookings = list(bookings_result.scalars().all()) - - # Group bookings by date - bookings_by_date = {} - for booking in bookings: - if booking.booking_date not in bookings_by_date: - bookings_by_date[booking.booking_date] = [] - bookings_by_date[booking.booking_date].append(booking) - - # Build stats for each day - days_stats = [] - day_names = ["Today", "Tomorrow"] - - for i in range(days): - current_date = today + timedelta(days=i) - occupancy = occupancy_by_date.get(current_date) - day_bookings = bookings_by_date.get(current_date, []) - - # Day name - if i < len(day_names): - day_name = day_names[i] - else: - day_name = current_date.strftime("%A") # Day of week - - # Default values - arrival_count = 0 - arrival_guests = 0 - matched_arrivals = 0 - unmatched_arrivals = 0 - opportunity_guests = 0 - - if occupancy and occupancy.arrival_count: - arrival_count = occupancy.arrival_count or 0 - arrival_details = occupancy.arrival_booking_details or [] - - # Calculate total guests from arrivals - arrival_guests = sum(detail.get("num_guests", 0) for detail in arrival_details) - - # Get hotel booking numbers from Resos - hotel_refs = {b.hotel_booking_number for b in day_bookings if b.hotel_booking_number} - - # Match arrivals with table bookings - matched = [] - unmatched = [] - - for detail in arrival_details: - booking_ref = detail.get("booking_reference", "") - booking_id = detail.get("booking_id", "") - - # Check if this arrival has a matching table booking - if booking_ref in hotel_refs or booking_id in hotel_refs: - matched.append(detail) - else: - unmatched.append(detail) - - matched_arrivals = len(matched) - unmatched_arrivals = len(unmatched) - opportunity_guests = sum(detail.get("num_guests", 0) for detail in unmatched) - - # Table booking stats - table_bookings = len(day_bookings) - table_covers = sum(b.people for b in day_bookings) - - days_stats.append(ArrivalDayStats( - date=current_date, - day_name=day_name, - arrival_count=arrival_count, - arrival_guests=arrival_guests, - table_bookings=table_bookings, - table_covers=table_covers, - matched_arrivals=matched_arrivals, - unmatched_arrivals=unmatched_arrivals, - opportunity_guests=opportunity_guests - )) - - return ArrivalDashboardResponse(days=days_stats, service_filter_name=service_filter_name) diff --git a/backend/api/public.py b/backend/api/public.py deleted file mode 100644 index 7f92f96..0000000 --- a/backend/api/public.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Public API endpoints - NO AUTHENTICATION REQUIRED. - -These endpoints are designed for sharing with external parties (e.g., suppliers) -via hash-based URLs that don't require login. -""" -import os -from fastapi import APIRouter, HTTPException -from fastapi.responses import Response -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from fastapi import Depends - -from database import get_db -from models.dispute import DisputeAttachment -from models.settings import KitchenSettings -from services.nextcloud_service import NextcloudService - -router = APIRouter() - - -@router.get("/attachments/{public_hash}") -async def get_public_attachment( - public_hash: str, - db: AsyncSession = Depends(get_db) -): - """ - View a dispute attachment publicly via its hash. - - This endpoint does NOT require authentication, allowing suppliers - to view attached images/documents via shareable links in emails. - """ - # Find attachment by public hash - result = await db.execute( - select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash) - ) - attachment = result.scalar_one_or_none() - - if not attachment: - raise HTTPException(status_code=404, detail="Attachment not found") - - # Get file content - content = None - - # Try local file first - if attachment.file_storage_location == "local" and attachment.file_path: - if os.path.exists(attachment.file_path): - with open(attachment.file_path, 'rb') as f: - content = f.read() - - # Try Nextcloud if local not found - if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path: - # Get kitchen settings for Nextcloud credentials - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if settings and settings.nextcloud_enabled: - nc = NextcloudService( - settings.nextcloud_host, - settings.nextcloud_username, - settings.nextcloud_password, - "" - ) - success, nc_content = await nc.download_file(attachment.nextcloud_path) - await nc.close() - - if success: - content = nc_content - - if content is None: - raise HTTPException(status_code=404, detail="File not found") - - # Determine if browser should display inline or download - # Images and PDFs display inline, others download - inline_types = [ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - 'application/pdf' - ] - - disposition = "inline" if attachment.file_type in inline_types else "attachment" - - return Response( - content=content, - media_type=attachment.file_type, - headers={ - "Content-Disposition": f'{disposition}; filename="{attachment.file_name}"', - "Cache-Control": "private, max-age=3600" # Cache for 1 hour - } - ) - - -@router.get("/attachments/{public_hash}/info") -async def get_public_attachment_info( - public_hash: str, - db: AsyncSession = Depends(get_db) -): - """ - Get attachment metadata without downloading the file. - Useful for email previews or link unfurling. - """ - result = await db.execute( - select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash) - ) - attachment = result.scalar_one_or_none() - - if not attachment: - raise HTTPException(status_code=404, detail="Attachment not found") - - return { - "file_name": attachment.file_name, - "file_type": attachment.file_type, - "file_size_bytes": attachment.file_size_bytes, - "attachment_type": attachment.attachment_type, - "description": attachment.description, - "uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None - } diff --git a/backend/api/purchase_orders.py b/backend/api/purchase_orders.py deleted file mode 100644 index d6275e1..0000000 --- a/backend/api/purchase_orders.py +++ /dev/null @@ -1,835 +0,0 @@ -""" -Purchase Order API endpoints — full CRUD, attachment, product search, budget view, -preview (HTML), and email sending. -""" -import os -import uuid -import logging -from datetime import date -from decimal import Decimal -from typing import Optional -from html import escape as html_escape - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, or_, func, delete -from sqlalchemy.orm import selectinload -from pydantic import BaseModel, field_serializer - -from database import get_db -from models.user import User, Kitchen -from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem -from models.supplier import Supplier -from models.settings import KitchenSettings -from models.line_item import LineItem -from models.invoice import Invoice -from auth import get_current_user, require_cap, get_current_user_from_token -from services.email_service import EmailService - -logger = logging.getLogger(__name__) - -router = APIRouter() - -UPLOAD_DIR = "data/po_attachments" - -# ── Pydantic schemas ────────────────────────────────────────────────────────── - -class LineItemIn(BaseModel): - product_id: Optional[int] = None - product_code: Optional[str] = None - description: str - unit: Optional[str] = None - unit_price: Decimal - quantity: Decimal - total: Decimal - line_number: int = 0 - source: str = "manual" - - -class PurchaseOrderCreate(BaseModel): - supplier_id: int - order_date: date - order_type: str # 'itemised' or 'single_value' - total_amount: Optional[Decimal] = None - order_reference: Optional[str] = None - notes: Optional[str] = None - status: str = "DRAFT" - line_items: list[LineItemIn] = [] - - -class PurchaseOrderUpdate(BaseModel): - supplier_id: Optional[int] = None - order_date: Optional[date] = None - order_type: Optional[str] = None - total_amount: Optional[Decimal] = None - order_reference: Optional[str] = None - notes: Optional[str] = None - status: Optional[str] = None - line_items: Optional[list[LineItemIn]] = None - - -class StatusUpdate(BaseModel): - status: str - - -class LineItemOut(BaseModel): - id: int - product_id: Optional[int] - product_code: Optional[str] - description: str - unit: Optional[str] - unit_price: Decimal - quantity: Decimal - total: Decimal - line_number: int - source: str - - @field_serializer('unit_price', 'quantity', 'total') - def ser(self, v: Decimal) -> float: - return float(v) - - -class PurchaseOrderOut(BaseModel): - id: int - kitchen_id: int - supplier_id: int - supplier_name: Optional[str] = None - order_date: date - order_type: str - status: str - total_amount: Optional[Decimal] - order_reference: Optional[str] - notes: Optional[str] - attachment_path: Optional[str] - attachment_original_name: Optional[str] - linked_invoice_id: Optional[int] - created_by: int - created_by_name: Optional[str] = None - created_at: str - updated_at: str - line_items: list[LineItemOut] = [] - - @field_serializer('total_amount') - def ser_amount(self, v: Optional[Decimal]) -> Optional[float]: - return float(v) if v is not None else None - - -class BudgetPO(BaseModel): - id: int - order_type: str - status: str - total_amount: Optional[float] - order_reference: Optional[str] - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -def po_to_out(po: PurchaseOrder) -> PurchaseOrderOut: - return PurchaseOrderOut( - id=po.id, - kitchen_id=po.kitchen_id, - supplier_id=po.supplier_id, - supplier_name=po.supplier.name if po.supplier else None, - order_date=po.order_date, - order_type=po.order_type, - status=po.status, - total_amount=po.total_amount, - order_reference=po.order_reference, - notes=po.notes, - attachment_path=po.attachment_path, - attachment_original_name=po.attachment_original_name, - linked_invoice_id=po.linked_invoice_id, - created_by=po.created_by, - created_by_name=po.created_by_user.name if po.created_by_user else None, - created_at=po.created_at.isoformat() if po.created_at else "", - updated_at=po.updated_at.isoformat() if po.updated_at else "", - line_items=[ - LineItemOut( - id=li.id, - product_id=li.product_id, - product_code=li.product_code, - description=li.description, - unit=li.unit, - unit_price=li.unit_price, - quantity=li.quantity, - total=li.total, - line_number=li.line_number, - source=li.source, - ) - for li in (po.line_items or []) - ], - ) - - -def _calc_itemised_total(items: list[LineItemIn]) -> Decimal: - return sum((i.total for i in items), Decimal("0")) - - -async def _load_po(db: AsyncSession, po_id: int, kitchen_id: int) -> PurchaseOrder: - result = await db.execute( - select(PurchaseOrder) - .where( - PurchaseOrder.id == po_id, - PurchaseOrder.kitchen_id == kitchen_id, - ) - .options( - selectinload(PurchaseOrder.line_items), - selectinload(PurchaseOrder.supplier), - selectinload(PurchaseOrder.created_by_user), - ) - ) - po = result.scalar_one_or_none() - if not po: - raise HTTPException(status_code=404, detail="Purchase order not found") - return po - - -# ── CRUD Endpoints ──────────────────────────────────────────────────────────── - -@router.post("/", response_model=PurchaseOrderOut) -async def create_purchase_order( - data: PurchaseOrderCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - # Validate supplier belongs to this kitchen - sup = await db.execute( - select(Supplier).where( - Supplier.id == data.supplier_id, - Supplier.kitchen_id == current_user.kitchen_id, - ) - ) - if not sup.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Invalid supplier") - - total = data.total_amount if data.order_type == "single_value" else _calc_itemised_total(data.line_items) - - po = PurchaseOrder( - kitchen_id=current_user.kitchen_id, - supplier_id=data.supplier_id, - order_date=data.order_date, - order_type=data.order_type, - status=data.status if data.status in ("DRAFT", "PENDING") else "DRAFT", - total_amount=total, - order_reference=data.order_reference, - notes=data.notes, - created_by=current_user.id, - updated_by=current_user.id, - ) - db.add(po) - await db.flush() - - for idx, li in enumerate(data.line_items): - db.add(PurchaseOrderLineItem( - purchase_order_id=po.id, - kitchen_id=current_user.kitchen_id, - product_id=li.product_id, - product_code=li.product_code, - description=li.description, - unit=li.unit, - unit_price=li.unit_price, - quantity=li.quantity, - total=li.total, - line_number=li.line_number or idx, - source=li.source, - )) - - await db.commit() - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -@router.get("/", response_model=list[PurchaseOrderOut]) -async def list_purchase_orders( - status: Optional[str] = None, - supplier_id: Optional[int] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - limit: int = 100, - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - q = ( - select(PurchaseOrder) - .where(PurchaseOrder.kitchen_id == current_user.kitchen_id) - .options( - selectinload(PurchaseOrder.line_items), - selectinload(PurchaseOrder.supplier), - selectinload(PurchaseOrder.created_by_user), - ) - .order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.id.desc()) - ) - - if status: - statuses = [s.strip().upper() for s in status.split(",")] - q = q.where(PurchaseOrder.status.in_(statuses)) - if supplier_id: - q = q.where(PurchaseOrder.supplier_id == supplier_id) - if date_from: - q = q.where(PurchaseOrder.order_date >= date_from) - if date_to: - q = q.where(PurchaseOrder.order_date <= date_to) - - q = q.offset(offset).limit(limit) - result = await db.execute(q) - return [po_to_out(po) for po in result.scalars().all()] - - -@router.get("/products/search") -async def search_products_for_po( - query: str, - supplier_id: Optional[int] = None, - limit: int = 20, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Search products from invoice line items, optionally filtered by supplier.""" - q = ( - select( - LineItem.description, - LineItem.product_code, - LineItem.unit, - LineItem.unit_price, - Supplier.name.label("supplier_name"), - Invoice.supplier_id.label("sup_id"), - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) - .where( - and_( - Invoice.kitchen_id == current_user.kitchen_id, - or_( - LineItem.description.ilike(f"%{query}%"), - LineItem.product_code.ilike(f"%{query}%"), - ), - ) - ) - .order_by(Invoice.invoice_date.desc()) - ) - - if supplier_id: - q = q.where(Invoice.supplier_id == supplier_id) - - q = q.limit(limit * 3) - result = await db.execute(q) - rows = result.all() - - seen: set[str] = set() - products = [] - for row in rows: - key = (row.description or "").lower() - if key and key not in seen: - seen.add(key) - products.append({ - "id": 0, - "name": row.description, - "product_code": row.product_code, - "supplier_name": row.supplier_name, - "unit": row.unit, - "last_price": float(row.unit_price) if row.unit_price else None, - }) - if len(products) >= limit: - break - - return products - - -@router.get("/by-date") -async def get_pos_by_date( - week_start: date, - week_end: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """POs grouped by supplier_id → date for budget table integration.""" - result = await db.execute( - select(PurchaseOrder) - .where( - PurchaseOrder.kitchen_id == current_user.kitchen_id, - PurchaseOrder.status.in_(["DRAFT", "PENDING"]), - PurchaseOrder.order_date >= week_start, - PurchaseOrder.order_date <= week_end, - ) - .options(selectinload(PurchaseOrder.line_items)) - ) - pos = result.scalars().all() - - grouped: dict[int, dict[str, list]] = {} - for po in pos: - sid = po.supplier_id - ds = po.order_date.isoformat() - if sid not in grouped: - grouped[sid] = {} - if ds not in grouped[sid]: - grouped[sid][ds] = [] - grouped[sid][ds].append(BudgetPO( - id=po.id, - order_type=po.order_type, - status=po.status, - total_amount=float(po.total_amount) if po.total_amount else None, - order_reference=po.order_reference, - ).model_dump()) - - return grouped - - -@router.get("/{po_id}", response_model=PurchaseOrderOut) -async def get_purchase_order( - po_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - return po_to_out(await _load_po(db, po_id, current_user.kitchen_id)) - - -@router.put("/{po_id}", response_model=PurchaseOrderOut) -async def update_purchase_order( - po_id: int, - data: PurchaseOrderUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - po = await _load_po(db, po_id, current_user.kitchen_id) - - if po.status in ("LINKED", "CLOSED", "CANCELLED"): - raise HTTPException(status_code=400, detail=f"Cannot edit PO with status {po.status}") - - if data.supplier_id is not None: - po.supplier_id = data.supplier_id - if data.order_date is not None: - po.order_date = data.order_date - if data.order_type is not None: - po.order_type = data.order_type - if data.order_reference is not None: - po.order_reference = data.order_reference - if data.notes is not None: - po.notes = data.notes - if data.status is not None and data.status in ("DRAFT", "PENDING"): - po.status = data.status - - # Replace line items if provided - if data.line_items is not None: - await db.execute( - delete(PurchaseOrderLineItem).where( - PurchaseOrderLineItem.purchase_order_id == po.id - ) - ) - for idx, li in enumerate(data.line_items): - db.add(PurchaseOrderLineItem( - purchase_order_id=po.id, - kitchen_id=current_user.kitchen_id, - product_id=li.product_id, - product_code=li.product_code, - description=li.description, - unit=li.unit, - unit_price=li.unit_price, - quantity=li.quantity, - total=li.total, - line_number=li.line_number or idx, - source=li.source, - )) - - # Recalculate total - if po.order_type == "single_value": - if data.total_amount is not None: - po.total_amount = data.total_amount - else: - items = data.line_items if data.line_items is not None else [] - po.total_amount = _calc_itemised_total(items) if items else po.total_amount - - po.updated_by = current_user.id - await db.commit() - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -@router.delete("/{po_id}") -async def delete_purchase_order( - po_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - po = await _load_po(db, po_id, current_user.kitchen_id) - allowed_statuses = ("DRAFT", "CANCELLED", "PENDING") if current_user.is_admin else ("DRAFT", "CANCELLED") - if po.status not in allowed_statuses: - raise HTTPException(status_code=400, detail="Only DRAFT or CANCELLED POs can be deleted") - - await db.delete(po) - await db.commit() - return {"ok": True} - - -@router.put("/{po_id}/status", response_model=PurchaseOrderOut) -async def update_po_status( - po_id: int, - data: StatusUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - po = await _load_po(db, po_id, current_user.kitchen_id) - allowed = { - "DRAFT": ["PENDING", "CANCELLED"], - "PENDING": ["DRAFT", "CLOSED", "CANCELLED"], - "LINKED": ["CLOSED"], - "CLOSED": [], - "CANCELLED": ["DRAFT"], - } - if data.status not in allowed.get(po.status, []): - raise HTTPException( - status_code=400, - detail=f"Cannot change status from {po.status} to {data.status}", - ) - po.status = data.status - po.updated_by = current_user.id - await db.commit() - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -# ── Attachment ──────────────────────────────────────────────────────────────── - -@router.post("/{po_id}/attachment", response_model=PurchaseOrderOut) -async def upload_attachment( - po_id: int, - file: UploadFile = File(...), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - po = await _load_po(db, po_id, current_user.kitchen_id) - os.makedirs(UPLOAD_DIR, exist_ok=True) - - ext = os.path.splitext(file.filename or "file")[1] - filename = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(UPLOAD_DIR, filename) - - contents = await file.read() - with open(filepath, "wb") as f: - f.write(contents) - - po.attachment_path = filepath - po.attachment_original_name = file.filename - po.updated_by = current_user.id - await db.commit() - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -@router.delete("/{po_id}/attachment", response_model=PurchaseOrderOut) -async def remove_attachment( - po_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - po = await _load_po(db, po_id, current_user.kitchen_id) - if po.attachment_path and os.path.exists(po.attachment_path): - os.remove(po.attachment_path) - po.attachment_path = None - po.attachment_original_name = None - po.updated_by = current_user.id - await db.commit() - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -# ── Preview & Email ────────────────────────────────────────────────────────── - -def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = "£") -> str: - """Generate a clean HTML page for PO preview / email body.""" - supplier = po.supplier - esc = html_escape - - # Kitchen letterhead - kitchen_name = esc(kitchen.kitchen_display_name or "") - addr_parts = [ - kitchen.kitchen_address_line1, - kitchen.kitchen_address_line2, - kitchen.kitchen_city, - kitchen.kitchen_postcode, - ] - addr_html = "
".join(esc(p) for p in addr_parts if p) - kitchen_phone = esc(kitchen.kitchen_phone or "") - kitchen_email = esc(kitchen.kitchen_email or "") - - # Supplier details - supplier_name = esc(supplier.name) if supplier else "Unknown" - account_number = esc(supplier.account_number or "") if supplier else "" - - # PO metadata - po_number = f"PO-{po.id}" - order_date = po.order_date.strftime("%d/%m/%Y") if po.order_date else "" - notes = esc(po.notes or "").replace("\n", "
") if po.notes else "" - - # Line items table - items_html = "" - if po.order_type == "itemised" and po.line_items: - rows = "" - for li in sorted(po.line_items, key=lambda x: x.line_number): - rows += f""" - {esc(li.product_code or "")} - {esc(li.description or "")} - {esc(li.unit or "")} - {currency}{li.unit_price:.2f} - {li.quantity:g} - {currency}{li.total:.2f} - """ - items_html = f""" - - - - - - - - - - - - {rows} -
CodeDescriptionUnitPriceQtyTotal
""" - elif po.order_type == "single_value": - items_html = f""" -

- Order Value: {currency}{float(po.total_amount or 0):.2f} - {f'
Order Ref: {esc(po.order_reference)}' if po.order_reference else ''} -
""" - - total_amount = float(po.total_amount or 0) - - return f""" - - - -Purchase Order {po_number} - - - -
- -
-
-

{kitchen_name}

-
{addr_html}
- {f'
Tel: {kitchen_phone}
' if kitchen_phone else ''} - {f'
{kitchen_email}
' if kitchen_email else ''} -
-
-

PURCHASE ORDER

-
{po_number}
-
-
- - -
-
-
Supplier
-
{supplier_name}
- {f'
Account: {account_number}
' if account_number else ''} -
-
-
Date
-
{order_date}
-
Status: {po.status}
-
-
- - - {items_html} - - -
- Total: {currency}{total_amount:.2f} -
- - - {f'
Notes:
{notes}
' if notes else ''} - - -
- -
-
- -""" - - -@router.get("/{po_id}/preview") -async def preview_purchase_order( - po_id: int, - token: Optional[str] = None, - db: AsyncSession = Depends(get_db), -): - """Return a print-friendly HTML preview of the purchase order (query-param auth).""" - if not token: - raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token") - current_user = await get_current_user_from_token(token, db) - if not current_user: - raise HTTPException(status_code=401, detail="Not authenticated") - po = await _load_po(db, po_id, current_user.kitchen_id) - - # Load kitchen settings for letterhead - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - kitchen = settings_result.scalar_one_or_none() - if not kitchen: - kitchen = KitchenSettings(kitchen_id=current_user.kitchen_id) - - currency = kitchen.currency_symbol or "£" - html = _build_po_html(po, kitchen, currency) - return HTMLResponse(content=html) - - -@router.post("/{po_id}/send-email") -async def send_po_email( - po_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Email the PO to the supplier's order_email address.""" - po = await _load_po(db, po_id, current_user.kitchen_id) - - # Validate supplier has an order email - if not po.supplier or not po.supplier.order_email: - raise HTTPException(status_code=400, detail="Supplier does not have an order email address configured") - - # Load kitchen settings for SMTP + letterhead - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - kitchen = settings_result.scalar_one_or_none() - if not kitchen or not kitchen.smtp_host or not kitchen.smtp_from_email: - raise HTTPException(status_code=400, detail="SMTP email is not configured in Settings") - - currency = kitchen.currency_symbol or "£" - html = _build_po_html(po, kitchen, currency) - - po_number = f"PO-{po.id}" - kitchen_name = kitchen.kitchen_display_name or "Kitchen" - subject = f"Purchase Order {po_number} from {kitchen_name}" - - email_service = EmailService(kitchen) - success = email_service.send_email( - to_email=po.supplier.order_email, - subject=subject, - html_body=html, - plain_body=f"Please find attached Purchase Order {po_number}. Total: {currency}{float(po.total_amount or 0):.2f}", - ) - - if not success: - raise HTTPException(status_code=500, detail="Failed to send email. Check SMTP settings.") - - # Update status to PENDING if currently DRAFT - if po.status == "DRAFT": - po.status = "PENDING" - po.updated_by = current_user.id - await db.commit() - - return {"ok": True, "message": f"PO emailed to {po.supplier.order_email}", "new_status": po.status} - - -# ── Invoice Matching ───────────────────────────────────────────────────────── - -class LinkRequest(BaseModel): - invoice_id: int - - -@router.get("/matching/for-invoice") -async def get_matching_pos( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Find pending POs that match a given invoice (by supplier).""" - from services.po_matching import find_matching_pos - - # Load the invoice - inv_result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id, - ) - ) - invoice = inv_result.scalar_one_or_none() - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - if not invoice.supplier_id: - return {"matches": [], "linked_po": None} - - # Check if invoice already has a linked PO - linked_result = await db.execute( - select(PurchaseOrder) - .where( - PurchaseOrder.kitchen_id == current_user.kitchen_id, - PurchaseOrder.linked_invoice_id == invoice_id, - PurchaseOrder.status == "LINKED", - ) - .options( - selectinload(PurchaseOrder.supplier), - selectinload(PurchaseOrder.line_items), - ) - ) - linked_po = linked_result.scalar_one_or_none() - - if linked_po: - return { - "matches": [], - "linked_po": { - "po_id": linked_po.id, - "order_date": linked_po.order_date.isoformat() if linked_po.order_date else None, - "total_amount": float(linked_po.total_amount) if linked_po.total_amount else None, - "order_reference": linked_po.order_reference, - "status": linked_po.status, - "order_type": linked_po.order_type, - }, - } - - # Find matching POs - matches = await find_matching_pos( - db, - kitchen_id=current_user.kitchen_id, - supplier_id=invoice.supplier_id, - invoice_date=invoice.invoice_date, - invoice_total=invoice.total, - ) - - return {"matches": matches, "linked_po": None} - - -@router.post("/{po_id}/link", response_model=PurchaseOrderOut) -async def link_po_to_invoice( - po_id: int, - data: LinkRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Link a PO to an invoice. Sets PO status to LINKED.""" - from services.po_matching import link_po_to_invoice as do_link - - po = await do_link(db, po_id, data.invoice_id, current_user.kitchen_id, current_user.id) - if not po: - raise HTTPException(status_code=404, detail="PO or invoice not found") - - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) - - -@router.post("/{po_id}/unlink", response_model=PurchaseOrderOut) -async def unlink_po_from_invoice( - po_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Unlink a PO from its invoice. Returns PO to PENDING status.""" - from services.po_matching import unlink_po as do_unlink - - po = await do_unlink(db, po_id, current_user.kitchen_id, current_user.id) - if not po: - raise HTTPException(status_code=404, detail="PO not found") - - return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) diff --git a/backend/api/recipes.py b/backend/api/recipes.py deleted file mode 100644 index 7cb4b42..0000000 --- a/backend/api/recipes.py +++ /dev/null @@ -1,2318 +0,0 @@ -""" -Recipe API — CRUD, costing, sub-recipe cycle check, scaling, menu sections, -cost snapshots, print HTML (full + kitchen card). -""" -import os -import uuid -import logging -from datetime import date, datetime, timedelta -from decimal import Decimal -from typing import Optional -from html import escape as html_escape - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, text, and_, delete -from sqlalchemy.orm import selectinload -from pydantic import BaseModel, field_serializer - -from database import get_db -from models.user import User -from models.recipe import ( - Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe, - RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshot, -) -from models.ingredient import Ingredient, IngredientSource -from models.food_flag import RecipeFlag, FoodFlagCategory, FoodFlag -from models.ingredient import IngredientFlag, IngredientFlagNone -from models.settings import KitchenSettings -from auth import get_current_user, require_cap, get_current_user_from_token -from api.ingredients import convert_to_standard, UNIT_CONVERSIONS -from models.menu import Menu, MenuItem - -logger = logging.getLogger(__name__) - -router = APIRouter() - -DATA_DIR = "/app/data" - - -# ── Pydantic schemas ───────────────────────────────────────────────────────── - -class MenuSectionCreate(BaseModel): - name: str - sort_order: int = 0 - section_type: str = "recipe" # "recipe" | "dish" - -class MenuSectionUpdate(BaseModel): - name: Optional[str] = None - sort_order: Optional[int] = None - -class MenuSectionResponse(BaseModel): - id: int - name: str - sort_order: int - section_type: str = "recipe" - recipe_count: int = 0 - -class RecipeCreate(BaseModel): - name: str - recipe_type: str = "component" # "component" | "dish" - menu_section_id: Optional[int] = None - description: Optional[str] = None - batch_portions: int = 1 - batch_output_type: str = "portions" # "portions" | "bulk" - batch_yield_qty: Optional[float] = None - batch_yield_unit: Optional[str] = None # g, kg, ml, ltr - prep_time_minutes: Optional[int] = None - cook_time_minutes: Optional[int] = None - notes: Optional[str] = None - -class RecipeUpdate(BaseModel): - name: Optional[str] = None - recipe_type: Optional[str] = None - menu_section_id: Optional[int] = None - description: Optional[str] = None - batch_portions: Optional[int] = None - batch_output_type: Optional[str] = None - batch_yield_qty: Optional[float] = None - batch_yield_unit: Optional[str] = None - prep_time_minutes: Optional[int] = None - cook_time_minutes: Optional[int] = None - notes: Optional[str] = None - is_archived: Optional[bool] = None - kds_menu_item_name: Optional[str] = None - sambapos_portion_name: Optional[str] = None - gross_sell_price: Optional[float] = None - -class IngredientAdd(BaseModel): - ingredient_id: int - quantity: float - unit: Optional[str] = None # override display unit (e.g. kg when ingredient std is g) - yield_percent: float = 100.0 - notes: Optional[str] = None - sort_order: int = 0 - -class IngredientUpdateSchema(BaseModel): - quantity: Optional[float] = None - unit: Optional[str] = None - yield_percent: Optional[float] = None - notes: Optional[str] = None - sort_order: Optional[int] = None - -class SubRecipeAdd(BaseModel): - child_recipe_id: int - portions_needed: float - portions_needed_unit: Optional[str] = None # override unit (e.g. ml when child yields ltr) - notes: Optional[str] = None - sort_order: int = 0 - -class SubRecipeUpdateSchema(BaseModel): - portions_needed: Optional[float] = None - portions_needed_unit: Optional[str] = None - notes: Optional[str] = None - sort_order: Optional[int] = None - -class StepCreate(BaseModel): - title: Optional[str] = None - instruction: str - step_number: int = 0 - duration_minutes: Optional[int] = None - notes: Optional[str] = None - -class StepUpdate(BaseModel): - title: Optional[str] = None - instruction: Optional[str] = None - step_number: Optional[int] = None - duration_minutes: Optional[int] = None - notes: Optional[str] = None - -class StepReorder(BaseModel): - step_ids: list[int] - -class IngredientReorder(BaseModel): - ingredient_ids: list[int] - -class SubRecipeReorder(BaseModel): - sub_recipe_ids: list[int] - -class RecipeListItem(BaseModel): - id: int - name: str - recipe_type: str - menu_section_id: Optional[int] = None - menu_section_name: Optional[str] = None - batch_portions: int = 1 - batch_output_type: str = "portions" - batch_yield_qty: Optional[float] = None - batch_yield_unit: Optional[str] = None - output_unit: str = "portion" - cost_per_portion: Optional[float] = None - total_cost: Optional[float] = None - is_archived: bool = False - prep_time_minutes: Optional[int] = None - cook_time_minutes: Optional[int] = None - flag_summary: list[dict] = [] - image_count: int = 0 - gross_sell_price: Optional[float] = None - kds_menu_item_name: Optional[str] = None - sambapos_portion_name: Optional[str] = None - created_at: str = "" - updated_at: str = "" - - -# ── Menu Section endpoints ─────────────────────────────────────────────────── - -@router.get("/menu-sections") -async def list_menu_sections( - section_type: Optional[str] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - count_sub = ( - select(Recipe.menu_section_id, func.count(Recipe.id).label("cnt")) - .where(Recipe.kitchen_id == user.kitchen_id, Recipe.is_archived == False) - .group_by(Recipe.menu_section_id) - .subquery() - ) - query = ( - select(MenuSection, func.coalesce(count_sub.c.cnt, 0).label("recipe_count")) - .outerjoin(count_sub, MenuSection.id == count_sub.c.menu_section_id) - .where(MenuSection.kitchen_id == user.kitchen_id) - ) - if section_type: - query = query.where(MenuSection.section_type == section_type) - query = query.order_by(MenuSection.sort_order, MenuSection.name) - result = await db.execute(query) - return [ - MenuSectionResponse(id=s.id, name=s.name, sort_order=s.sort_order, section_type=s.section_type, recipe_count=cnt) - for s, cnt in result.all() - ] - - -@router.post("/menu-sections") -async def create_menu_section( - data: MenuSectionCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - sec = MenuSection(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order, section_type=data.section_type) - db.add(sec) - await db.commit() - await db.refresh(sec) - return MenuSectionResponse(id=sec.id, name=sec.name, sort_order=sec.sort_order) - - -@router.patch("/menu-sections/{section_id}") -async def update_menu_section( - section_id: int, - data: MenuSectionUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) - ) - sec = result.scalar_one_or_none() - if not sec: - raise HTTPException(404, "Section not found") - if data.name is not None: - sec.name = data.name - if data.sort_order is not None: - sec.sort_order = data.sort_order - await db.commit() - return {"ok": True} - - -@router.delete("/menu-sections/{section_id}") -async def delete_menu_section( - section_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) - ) - sec = result.scalar_one_or_none() - if not sec: - raise HTTPException(404, "Section not found") - # Null out recipes in this section - from sqlalchemy import update - await db.execute( - update(Recipe).where(Recipe.menu_section_id == section_id).values(menu_section_id=None) - ) - await db.delete(sec) - await db.commit() - return {"ok": True} - - -# ── Default recipe sections and dish courses ──────────────────────────────── - -DEFAULT_RECIPE_SECTIONS = [ - ("Meats & Protein", 0), - ("Sauces & Jus", 1), - ("Starch & Vegetables", 2), - ("Sides & Accompaniments", 3), - ("Pastry & Dessert", 4), - ("Garnish & Toppings", 5), - ("Marinades & Glazes", 6), - ("Stews & Casseroles", 7), -] - -DEFAULT_DISH_COURSES = [ - ("Starter", 0), - ("Main", 1), - ("Dessert", 2), - ("Side", 3), - ("Specials", 4), -] - - -@router.post("/menu-sections/seed-defaults") -async def seed_default_menu_sections( - section_type: str = Query("recipe"), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - if section_type not in ("recipe", "dish"): - raise HTTPException(400, "section_type must be 'recipe' or 'dish'") - - defaults = DEFAULT_RECIPE_SECTIONS if section_type == "recipe" else DEFAULT_DISH_COURSES - kid = user.kitchen_id - created = 0 - for name, sort_order in defaults: - exists = await db.execute( - select(MenuSection).where( - MenuSection.kitchen_id == kid, - MenuSection.name == name, - MenuSection.section_type == section_type, - ) - ) - if exists.scalar_one_or_none(): - continue - db.add(MenuSection(kitchen_id=kid, name=name, section_type=section_type, sort_order=sort_order)) - created += 1 - await db.commit() - return {"ok": True, "created": created} - - -# ── Dashboard Stats ────────────────────────────────────────────────────────── - -@router.get("/dashboard-stats") -async def recipe_dashboard_stats( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Lightweight stats for the main dashboard widget.""" - kid = user.kitchen_id - - total = (await db.execute( - select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) - )).scalar() or 0 - - dishes = (await db.execute( - select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.recipe_type == "dish", Recipe.is_archived == False) - )).scalar() or 0 - - component = total - dishes - - # Ingredients with no sources - unmapped = (await db.execute( - select(func.count()).select_from(Ingredient).where( - Ingredient.kitchen_id == kid, - Ingredient.is_archived == False, - ~Ingredient.id.in_( - select(IngredientSource.ingredient_id).where(IngredientSource.kitchen_id == kid) - ), - ) - )).scalar() or 0 - - # Recipes without any cost snapshots - without_costing = (await db.execute( - select(func.count()).select_from(Recipe).where( - Recipe.kitchen_id == kid, - Recipe.is_archived == False, - ~Recipe.id.in_( - select(RecipeCostSnapshot.recipe_id).distinct() - ), - ) - )).scalar() or 0 - - # Dishes missing allergen assessment - # Find required flag categories for this kitchen - req_cats = (await db.execute( - select(FoodFlagCategory.id).where( - FoodFlagCategory.kitchen_id == kid, - FoodFlagCategory.required == True, - ) - )).scalars().all() - - dishes_missing_allergens = 0 - dishes_missing_list: list[dict] = [] - - if req_cats: - # Get all dish recipes (non-archived) - dish_rows = (await db.execute( - select(Recipe.id, Recipe.name).where( - Recipe.kitchen_id == kid, - Recipe.recipe_type == "dish", - Recipe.is_archived == False, - ) - )).all() - - for dish_id, dish_name in dish_rows: - # Get all ingredient IDs used by this dish (direct + via sub-recipes) - direct_ings = (await db.execute( - select(RecipeIngredient.ingredient_id).where( - RecipeIngredient.recipe_id == dish_id - ) - )).scalars().all() - - sub_recipe_ids = (await db.execute( - select(RecipeSubRecipe.child_recipe_id).where( - RecipeSubRecipe.parent_recipe_id == dish_id - ) - )).scalars().all() - - sub_ings: list[int] = [] - for sr_id in sub_recipe_ids: - sr_ings = (await db.execute( - select(RecipeIngredient.ingredient_id).where( - RecipeIngredient.recipe_id == sr_id - ) - )).scalars().all() - sub_ings.extend(sr_ings) - - all_ing_ids = set(direct_ings) | set(sub_ings) - if not all_ing_ids: - continue - - # Check each ingredient against required categories - has_unassessed = False - for ing_id in all_ing_ids: - for cat_id in req_cats: - # Check if ingredient has any flag in this category - has_flag = (await db.execute( - select(IngredientFlag.id).where( - IngredientFlag.ingredient_id == ing_id, - IngredientFlag.food_flag_id.in_( - select(FoodFlag.id).where(FoodFlag.category_id == cat_id) - ), - ).limit(1) - )).scalar_one_or_none() - - if not has_flag: - # Check if ingredient has "none" for this category - has_none = (await db.execute( - select(IngredientFlagNone.id).where( - IngredientFlagNone.ingredient_id == ing_id, - IngredientFlagNone.category_id == cat_id, - ).limit(1) - )).scalar_one_or_none() - - if not has_none: - has_unassessed = True - break - if has_unassessed: - break - - if has_unassessed: - dishes_missing_allergens += 1 - dishes_missing_list.append({"id": dish_id, "name": dish_name}) - - # Recipes affected by ingredient price changes in last 14 days - cutoff = datetime.utcnow() - timedelta(days=14) - price_change_logs = (await db.execute( - select(RecipeChangeLog.recipe_id).where( - RecipeChangeLog.created_at >= cutoff, - RecipeChangeLog.user_id == None, - (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), - RecipeChangeLog.recipe_id.in_( - select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) - ), - ).distinct() - )).scalars().all() - - # Menu items needing republishing - from api.menus import _compute_staleness - menu_result = await db.execute( - select(Menu).options(selectinload(Menu.items)).where( - Menu.kitchen_id == kid, Menu.is_active == True - ) - ) - active_menus = menu_result.scalars().all() - stale_menu_items = 0 - stale_menu_names: list[str] = [] - for menu in active_menus: - if menu.items: - staleness = await _compute_staleness(list(menu.items), db) - menu_stale = sum(1 for s in staleness.values() if s.get("is_stale")) - if menu_stale > 0: - stale_menu_items += menu_stale - stale_menu_names.append(menu.name) - - return { - "total_recipes": total, - "dish_count": dishes, - "component_recipes": component, - "unmapped_ingredients": unmapped, - "recipes_without_costing": without_costing, - "dishes_missing_allergens": dishes_missing_allergens, - "dishes_missing_allergens_list": dishes_missing_list, - "recipes_with_price_changes": len(price_change_logs), - "stale_menu_items": stale_menu_items, - "stale_menu_names": stale_menu_names, - } - - -@router.get("/price-impact") -async def price_impact_report( - days: int = Query(14, ge=1, le=365), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Recipes affected by ingredient price changes within the given period.""" - kid = user.kitchen_id - cutoff = datetime.utcnow() - timedelta(days=days) - - # Find price-change log entries within the period - log_result = await db.execute( - select(RecipeChangeLog).where( - RecipeChangeLog.created_at >= cutoff, - RecipeChangeLog.user_id == None, - (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), - RecipeChangeLog.recipe_id.in_( - select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) - ), - ).order_by(RecipeChangeLog.created_at.desc()) - ) - logs = log_result.scalars().all() - - if not logs: - return {"days": days, "recipes": []} - - import re - price_change_re = re.compile( - r"^(.+?) price changed: £([\d.]+)(/(\w+))? → £([\d.]+)(/(\w+))?$" - ) - price_set_re = re.compile( - r"^(.+?) price set: £([\d.]+)(/(\w+))?$" - ) - - # Batch-load invoice numbers for logs that have source_invoice_id - invoice_ids = {log.source_invoice_id for log in logs if log.source_invoice_id} - invoice_map: dict[int, str] = {} - if invoice_ids: - from models.invoice import Invoice - inv_result = await db.execute( - select(Invoice.id, Invoice.invoice_number).where(Invoice.id.in_(invoice_ids)) - ) - invoice_map = {row[0]: row[1] for row in inv_result.fetchall()} - - # Group log entries by recipe, parsing structured data from summary - recipe_changes: dict[int, list[dict]] = {} - for log in logs: - entry: dict = { - "summary": log.change_summary, - "date": str(log.created_at.date()) if log.created_at else None, - "ingredient_name": None, - "old_price": None, - "new_price": None, - "unit": None, - "source_invoice_id": log.source_invoice_id, - "source_invoice_number": invoice_map.get(log.source_invoice_id) if log.source_invoice_id else None, - } - m = price_change_re.match(log.change_summary) - if m: - entry["ingredient_name"] = m.group(1) - entry["old_price"] = float(m.group(2)) - entry["new_price"] = float(m.group(5)) - entry["unit"] = m.group(4) or m.group(7) - else: - m2 = price_set_re.match(log.change_summary) - if m2: - entry["ingredient_name"] = m2.group(1) - entry["new_price"] = float(m2.group(2)) - entry["unit"] = m2.group(4) - recipe_changes.setdefault(log.recipe_id, []).append(entry) - - # Get recipe info + cost snapshots for affected recipes - recipe_ids = list(recipe_changes.keys()) - recipe_result = await db.execute( - select(Recipe) - .options( - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), - selectinload(Recipe.sub_recipes) - .selectinload(RecipeSubRecipe.child_recipe) - .options( - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), - selectinload(Recipe.sub_recipes) - .selectinload(RecipeSubRecipe.child_recipe) - .selectinload(Recipe.ingredients) - .selectinload(RecipeIngredient.ingredient), - ), - ) - .where(Recipe.id.in_(recipe_ids)) - ) - recipes = {r.id: r for r in recipe_result.scalars().all()} - - def _build_ing_usage(recipe, scale: float = 1.0, depth: int = 0) -> dict[str, float]: - """Recursively build ingredient name -> qty_in_std_unit map, expanding sub-recipes.""" - usage: dict[str, float] = {} - for ri in recipe.ingredients: - if ri.ingredient and ri.quantity: - display_unit = ri.unit or ri.ingredient.standard_unit - qty_std = _convert_unit(float(ri.quantity), display_unit, ri.ingredient.standard_unit) - yld = float(ri.yield_percent) if ri.yield_percent else 100.0 - qty_effective = (qty_std / (yld / 100) if yld > 0 else qty_std) * scale - key = ri.ingredient.name.lower() - usage[key] = usage.get(key, 0) + qty_effective - if depth < 3: - try: - for sr in recipe.sub_recipes: - child = sr.child_recipe - if not child: - continue - child_output = _get_output_qty(child) - if not child_output: - continue - needed = float(sr.portions_needed) if sr.portions_needed else 0 - if not needed: - continue - # Convert portions_needed unit to child output unit if needed (bulk sub-recipes) - child_output_unit = (_get_output_unit(child)).lower().strip() - needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip() - if needed_unit != child_output_unit and child.batch_output_type == "bulk": - converted = _convert_unit(needed, needed_unit, child_output_unit) - if converted is not None: - needed = converted - sub_scale = scale * needed / child_output - child_usage = _build_ing_usage(child, sub_scale, depth + 1) - for k, v in child_usage.items(): - usage[k] = usage.get(k, 0) + v - except Exception: - pass # lazy load guard - return usage - - items = [] - for rid in recipe_ids: - r = recipes.get(rid) - if not r: - continue - - output_qty = _get_output_qty(r) - - # Build ingredient name -> usage map for cost impact calculation (including sub-recipes) - ing_usage = _build_ing_usage(r) - - # Calculate per-change cost impact - for change in recipe_changes[rid]: - impact = None - if change["old_price"] is not None and change["new_price"] is not None and change["ingredient_name"]: - qty = ing_usage.get(change["ingredient_name"].lower()) - if qty is not None: - price_diff = change["new_price"] - change["old_price"] - # Impact per output unit (portion/kg/etc) - impact = round(price_diff * qty / output_qty, 4) if output_qty else None - change["cost_impact"] = impact - - # Latest snapshot (current cost) - latest_snap = (await db.execute( - select(RecipeCostSnapshot).where( - RecipeCostSnapshot.recipe_id == rid, - ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) - )).scalar_one_or_none() - - # Snapshot just before the period (previous cost) - prev_snap = (await db.execute( - select(RecipeCostSnapshot).where( - RecipeCostSnapshot.recipe_id == rid, - RecipeCostSnapshot.snapshot_date < cutoff.date(), - ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) - )).scalar_one_or_none() - - current_cost = float(latest_snap.cost_per_portion) if latest_snap else None - previous_cost = float(prev_snap.cost_per_portion) if prev_snap else None - cost_change = None - cost_change_pct = None - if current_cost is not None and previous_cost is not None and previous_cost > 0: - cost_change = round(current_cost - previous_cost, 4) - cost_change_pct = round((cost_change / previous_cost) * 100, 1) - - items.append({ - "recipe_id": rid, - "recipe_name": r.name, - "recipe_type": r.recipe_type, - "output_unit": _get_output_unit(r), - "current_cost_per_unit": current_cost, - "previous_cost_per_unit": previous_cost, - "cost_change": cost_change, - "cost_change_pct": cost_change_pct, - "ingredient_changes": recipe_changes[rid], - }) - - # Sort by absolute cost change descending (biggest movers first) - items.sort(key=lambda x: abs(x["cost_change"] or 0), reverse=True) - - return {"days": days, "recipes": items} - - -# ── Recipe List & CRUD ─────────────────────────────────────────────────────── - -@router.get("") -async def list_recipes( - recipe_type: Optional[str] = Query(None), - menu_section_id: Optional[int] = Query(None), - search: Optional[str] = Query(None), - archived: bool = Query(False), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - query = ( - select(Recipe) - .options(selectinload(Recipe.menu_section), selectinload(Recipe.images)) - .where(Recipe.kitchen_id == user.kitchen_id) - ) - if not archived: - query = query.where(Recipe.is_archived == False) - if recipe_type: - query = query.where(Recipe.recipe_type == recipe_type) - if menu_section_id: - query = query.where(Recipe.menu_section_id == menu_section_id) - if search: - query = query.where(Recipe.name.ilike(f"%{search}%")) - - result = await db.execute(query.order_by(Recipe.name)) - recipes = result.scalars().all() - - items = [] - for r in recipes: - # Get latest cost snapshot - snap_result = await db.execute( - select(RecipeCostSnapshot) - .where(RecipeCostSnapshot.recipe_id == r.id) - .order_by(RecipeCostSnapshot.snapshot_date.desc()) - .limit(1) - ) - snap = snap_result.scalar_one_or_none() - - # Get flag summary (lightweight) - from api.food_flags import compute_recipe_flags - flags = await compute_recipe_flags(r.id, user.kitchen_id, db) - flag_summary = [ - {"name": f.flag_name, "code": f.flag_code, "icon": f.flag_icon, - "category": f.category_name, "propagation": f.propagation_type, - "active": f.is_active, "excludable": f.excludable_on_request} - for f in flags if f.is_active - ] - - items.append(RecipeListItem( - id=r.id, - name=r.name, - recipe_type=r.recipe_type, - menu_section_id=r.menu_section_id, - menu_section_name=r.menu_section.name if r.menu_section else None, - batch_portions=r.batch_portions, - batch_output_type=r.batch_output_type or "portions", - batch_yield_qty=float(r.batch_yield_qty) if r.batch_yield_qty else None, - batch_yield_unit=r.batch_yield_unit, - output_unit=_get_output_unit(r), - cost_per_portion=float(snap.cost_per_portion) if snap else None, - total_cost=float(snap.total_cost) if snap else None, - is_archived=r.is_archived, - prep_time_minutes=r.prep_time_minutes, - cook_time_minutes=r.cook_time_minutes, - flag_summary=flag_summary, - image_count=len(r.images) if r.images else 0, - gross_sell_price=float(r.gross_sell_price) if r.gross_sell_price else None, - kds_menu_item_name=r.kds_menu_item_name, - sambapos_portion_name=r.sambapos_portion_name, - created_at=str(r.created_at) if r.created_at else "", - updated_at=str(r.updated_at) if r.updated_at else "", - )) - - return items - - -@router.post("") -async def create_recipe( - data: RecipeCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - if data.recipe_type not in ("component", "dish"): - raise HTTPException(400, "recipe_type must be 'component' or 'dish'") - - # Validate bulk output - if data.batch_output_type == "bulk": - if not data.batch_yield_qty or data.batch_yield_qty <= 0: - raise HTTPException(400, "Bulk recipes require a positive batch_yield_qty") - if data.batch_yield_unit not in ("g", "kg", "ml", "ltr"): - raise HTTPException(400, "batch_yield_unit must be g, kg, ml, or ltr") - - recipe = Recipe( - kitchen_id=user.kitchen_id, - name=data.name.strip(), - recipe_type=data.recipe_type, - menu_section_id=data.menu_section_id, - description=data.description, - batch_portions=1 if data.batch_output_type == "bulk" else (data.batch_portions if data.recipe_type == "component" else 1), - batch_output_type=data.batch_output_type if data.recipe_type == "component" else "portions", - batch_yield_qty=Decimal(str(data.batch_yield_qty)) if data.batch_output_type == "bulk" and data.batch_yield_qty else None, - batch_yield_unit=data.batch_yield_unit if data.batch_output_type == "bulk" else None, - prep_time_minutes=data.prep_time_minutes, - cook_time_minutes=data.cook_time_minutes, - notes=data.notes, - created_by=user.id, - ) - db.add(recipe) - await db.commit() - await db.refresh(recipe) - return {"id": recipe.id, "name": recipe.name} - - -@router.get("/{recipe_id}") -async def get_recipe( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Recipe) - .options( - selectinload(Recipe.menu_section), - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), - selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), - selectinload(Recipe.steps), - selectinload(Recipe.images), - ) - .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - - # Build detailed response - ingredients = [] - for ri in sorted(recipe.ingredients, key=lambda x: x.sort_order): - ing = ri.ingredient - if not ing: - continue - display_unit = ri.unit or ing.standard_unit - # Get effective price (yield from recipe ingredient, not raw ingredient) - yld = float(ri.yield_percent) if ri.yield_percent else 100.0 - eff_price = None - if ing.sources: - priced = [s for s in ing.sources if s.price_per_std_unit] - if priced: - latest = max(priced, key=lambda s: s.latest_invoice_date or date.min) - raw = float(latest.price_per_std_unit) - eff_price = raw / (yld / 100) if yld > 0 else raw - is_manual_price = False - if eff_price is None and ing.manual_price: - eff_price = float(ing.manual_price) / (yld / 100) if yld > 0 else float(ing.manual_price) - is_manual_price = True - - # Convert quantity to standard unit for cost calculation - qty_in_std = _convert_unit(float(ri.quantity), display_unit, ing.standard_unit) - cost = qty_in_std * eff_price if eff_price and ri.quantity else None - - ingredients.append({ - "id": ri.id, - "ingredient_id": ing.id, - "ingredient_name": ing.name, - "quantity": float(ri.quantity), - "unit": display_unit, - "standard_unit": ing.standard_unit, - "compatible_units": _get_compatible_units(ing.standard_unit), - "yield_percent": yld, - "effective_price": round(eff_price, 6) if eff_price else None, - "cost": round(cost, 4) if cost else None, - "is_manual_price": is_manual_price if not (hasattr(ing, 'is_free') and ing.is_free) else False, - "has_no_price": (eff_price is None) if not (hasattr(ing, 'is_free') and ing.is_free) else False, - "notes": ri.notes, - "sort_order": ri.sort_order, - }) - - sub_recipes = [] - for sr in sorted(recipe.sub_recipes, key=lambda x: x.sort_order): - child = sr.child_recipe - if not child: - continue - # Calculate child cost from scratch (not from snapshots, which may be stale/missing) - child_cost_data = await _calc_recipe_cost(child.id, db) - child_total = child_cost_data.get("total_cost_recent", 0) or 0 - child_output_qty = _get_output_qty(child) - child_cost_per_portion = child_total / child_output_qty if child_total and child_output_qty else None - child_output_unit = _get_output_unit(child) - needed_unit = sr.portions_needed_unit or child_output_unit - # Convert to child output unit for costing - needed_in_output_unit = _convert_unit(float(sr.portions_needed), needed_unit, child_output_unit) - cost_contribution = None - if child_cost_per_portion and sr.portions_needed: - cost_contribution = needed_in_output_unit * child_cost_per_portion - - # Check if child recipe has any manual-priced or no-price ingredients - child_has_manual = any(ci.get("is_manual_price") for ci in child_cost_data.get("ingredients", [])) - child_has_no_price = any(ci.get("has_no_price") for ci in child_cost_data.get("ingredients", [])) - # Also check nested sub-recipe child ingredients recursively - def _check_subs(subs): - m, n = False, False - for s in (subs or []): - for ci in s.get("child_ingredients", []): - if ci.get("is_manual_price"): m = True - if ci.get("has_no_price"): n = True - sm, sn = _check_subs(s.get("child_sub_recipes", [])) - m = m or sm - n = n or sn - return m, n - sub_m, sub_n = _check_subs(child_cost_data.get("sub_recipes", [])) - child_has_manual = child_has_manual or sub_m - child_has_no_price = child_has_no_price or sub_n - - sub_recipes.append({ - "id": sr.id, - "child_recipe_id": child.id, - "child_recipe_name": child.name, - "child_recipe_type": child.recipe_type, - "batch_portions": child.batch_portions, - "batch_output_type": child.batch_output_type or "portions", - "batch_yield_qty": float(child.batch_yield_qty) if child.batch_yield_qty else None, - "batch_yield_unit": child.batch_yield_unit, - "output_qty": _get_output_qty(child), - "output_unit": child_output_unit, - "portions_needed": float(sr.portions_needed), - "portions_needed_unit": needed_unit, - "compatible_units": _get_compatible_units(child_output_unit), - "cost_per_portion": child_cost_per_portion, - "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, - "has_manual_price_ingredients": child_has_manual, - "has_no_price_ingredients": child_has_no_price, - "notes": sr.notes, - "sort_order": sr.sort_order, - }) - - steps = [ - { - "id": s.id, "step_number": s.step_number, "title": s.title, - "instruction": s.instruction, "image_path": s.image_path, - "duration_minutes": s.duration_minutes, "notes": s.notes, - } - for s in sorted(recipe.steps, key=lambda x: x.step_number) - ] - - images = [ - { - "id": img.id, "image_path": img.image_path, "caption": img.caption, - "image_type": img.image_type, "sort_order": img.sort_order, - } - for img in sorted(recipe.images, key=lambda x: x.sort_order) - ] - - return { - "id": recipe.id, - "name": recipe.name, - "recipe_type": recipe.recipe_type, - "menu_section_id": recipe.menu_section_id, - "menu_section_name": recipe.menu_section.name if recipe.menu_section else None, - "description": recipe.description, - "batch_portions": recipe.batch_portions, - "batch_output_type": recipe.batch_output_type or "portions", - "batch_yield_qty": float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None, - "batch_yield_unit": recipe.batch_yield_unit, - "output_qty": _get_output_qty(recipe), - "output_unit": _get_output_unit(recipe), - "prep_time_minutes": recipe.prep_time_minutes, - "cook_time_minutes": recipe.cook_time_minutes, - "notes": recipe.notes, - "is_archived": recipe.is_archived, - "kds_menu_item_name": recipe.kds_menu_item_name, - "sambapos_portion_name": recipe.sambapos_portion_name, - "gross_sell_price": float(recipe.gross_sell_price) if recipe.gross_sell_price else None, - "ingredients": ingredients, - "sub_recipes": sub_recipes, - "steps": steps, - "images": images, - "created_at": str(recipe.created_at) if recipe.created_at else "", - "updated_at": str(recipe.updated_at) if recipe.updated_at else "", - } - - -@router.patch("/{recipe_id}") -async def update_recipe( - recipe_id: int, - data: RecipeUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - - changes = [] - if data.name is not None and data.name != recipe.name: - changes.append(f"Name changed from '{recipe.name}' to '{data.name}'") - recipe.name = data.name.strip() - if data.recipe_type is not None and data.recipe_type != recipe.recipe_type: - changes.append(f"Type changed from '{recipe.recipe_type}' to '{data.recipe_type}'") - recipe.recipe_type = data.recipe_type - if data.menu_section_id is not None: - recipe.menu_section_id = data.menu_section_id - if data.description is not None: - recipe.description = data.description - if data.batch_portions is not None and data.batch_portions != recipe.batch_portions: - changes.append(f"Batch portions changed from {recipe.batch_portions} to {data.batch_portions}") - recipe.batch_portions = data.batch_portions - if data.batch_output_type is not None and data.batch_output_type != recipe.batch_output_type: - changes.append(f"Output type changed from '{recipe.batch_output_type}' to '{data.batch_output_type}'") - recipe.batch_output_type = data.batch_output_type - if data.batch_output_type == "bulk": - recipe.batch_portions = 1 - elif data.batch_output_type == "portions": - recipe.batch_yield_qty = None - recipe.batch_yield_unit = None - if data.batch_yield_qty is not None: - old_val = float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None - if old_val != data.batch_yield_qty: - changes.append(f"Yield qty changed from {old_val} to {data.batch_yield_qty}") - recipe.batch_yield_qty = Decimal(str(data.batch_yield_qty)) if data.batch_yield_qty > 0 else None - if data.batch_yield_unit is not None and data.batch_yield_unit != recipe.batch_yield_unit: - changes.append(f"Yield unit changed from '{recipe.batch_yield_unit}' to '{data.batch_yield_unit}'") - recipe.batch_yield_unit = data.batch_yield_unit - if data.prep_time_minutes is not None: - recipe.prep_time_minutes = data.prep_time_minutes - if data.cook_time_minutes is not None: - recipe.cook_time_minutes = data.cook_time_minutes - if data.notes is not None: - recipe.notes = data.notes - if data.is_archived is not None: - recipe.is_archived = data.is_archived - if data.kds_menu_item_name is not None: - recipe.kds_menu_item_name = data.kds_menu_item_name - if data.sambapos_portion_name is not None: - recipe.sambapos_portion_name = data.sambapos_portion_name if data.sambapos_portion_name else None - if data.gross_sell_price is not None: - recipe.gross_sell_price = data.gross_sell_price if data.gross_sell_price > 0 else None - - if changes: - db.add(RecipeChangeLog( - recipe_id=recipe_id, - change_summary="; ".join(changes), - user_id=user.id, - )) - - await db.commit() - if changes: - await _snapshot_recipe_and_parents(recipe_id, db, f"recipe_updated: {'; '.join(changes)}") - return {"ok": True} - - -@router.delete("/{recipe_id}") -async def archive_recipe( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - recipe.is_archived = True - await db.commit() - return {"ok": True} - - -@router.post("/{recipe_id}/duplicate") -async def duplicate_recipe( - recipe_id: int, - new_name: Optional[str] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Clone recipe with deep copy of ingredients, steps, images, flags. Sub-recipes are linked.""" - result = await db.execute( - select(Recipe) - .options( - selectinload(Recipe.ingredients), - selectinload(Recipe.sub_recipes), - selectinload(Recipe.steps), - selectinload(Recipe.images), - ) - .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) - ) - original = result.scalar_one_or_none() - if not original: - raise HTTPException(404, "Recipe not found") - - name = new_name or f"{original.name} (Copy)" - clone = Recipe( - kitchen_id=user.kitchen_id, - name=name, - recipe_type=original.recipe_type, - menu_section_id=original.menu_section_id, - description=original.description, - batch_portions=original.batch_portions, - batch_output_type=original.batch_output_type, - batch_yield_qty=original.batch_yield_qty, - batch_yield_unit=original.batch_yield_unit, - prep_time_minutes=original.prep_time_minutes, - cook_time_minutes=original.cook_time_minutes, - notes=original.notes, - created_by=user.id, - ) - db.add(clone) - await db.flush() - - # Copy ingredients - for ri in original.ingredients: - db.add(RecipeIngredient( - recipe_id=clone.id, - ingredient_id=ri.ingredient_id, - quantity=ri.quantity, - unit=ri.unit, - notes=ri.notes, - sort_order=ri.sort_order, - )) - - # Link sub-recipes (not deep copy) - for sr in original.sub_recipes: - db.add(RecipeSubRecipe( - parent_recipe_id=clone.id, - child_recipe_id=sr.child_recipe_id, - portions_needed=sr.portions_needed, - portions_needed_unit=sr.portions_needed_unit, - notes=sr.notes, - sort_order=sr.sort_order, - )) - - # Copy steps - for step in original.steps: - db.add(RecipeStep( - recipe_id=clone.id, - step_number=step.step_number, - title=step.title, - instruction=step.instruction, - duration_minutes=step.duration_minutes, - notes=step.notes, - )) - - # Copy images (copy file references, images are shared) - for img in original.images: - db.add(RecipeImage( - recipe_id=clone.id, - image_path=img.image_path, - caption=img.caption, - image_type=img.image_type, - sort_order=img.sort_order, - uploaded_by=user.id, - )) - - db.add(RecipeChangeLog( - recipe_id=clone.id, - change_summary=f"Duplicated from '{original.name}' (ID: {original.id})", - user_id=user.id, - )) - - await db.commit() - return {"id": clone.id, "name": clone.name} - - -# ── Recipe Ingredients ─────────────────────────────────────────────────────── - -@router.post("/{recipe_id}/ingredients") -async def add_recipe_ingredient( - recipe_id: int, - data: IngredientAdd, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - ing = await db.execute( - select(Ingredient).where(Ingredient.id == data.ingredient_id, Ingredient.kitchen_id == user.kitchen_id) - ) - ingredient = ing.scalar_one_or_none() - if not ingredient: - raise HTTPException(404, "Ingredient not found") - - # Store unit override if different from standard_unit - unit_override = None - display_unit = ingredient.standard_unit - if data.unit and data.unit != ingredient.standard_unit: - # Validate it's a compatible unit - if data.unit not in _get_compatible_units(ingredient.standard_unit): - raise HTTPException(400, f"Unit '{data.unit}' is not compatible with ingredient's standard unit '{ingredient.standard_unit}'") - unit_override = data.unit - display_unit = data.unit - - ri = RecipeIngredient( - recipe_id=recipe_id, - ingredient_id=data.ingredient_id, - quantity=Decimal(str(data.quantity)), - unit=unit_override, - yield_percent=Decimal(str(data.yield_percent)), - notes=data.notes, - sort_order=data.sort_order, - ) - db.add(ri) - - db.add(RecipeChangeLog( - recipe_id=recipe_id, - change_summary=f"Added {ingredient.name} ({data.quantity}{display_unit})", - user_id=user.id, - )) - - await db.commit() - await db.refresh(ri) - await _snapshot_recipe_and_parents(recipe_id, db, f"ingredient_added: {ingredient.name}") - return {"id": ri.id} - - -@router.patch("/recipe-ingredients/{ri_id}") -async def update_recipe_ingredient( - ri_id: int, - data: IngredientUpdateSchema, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - ri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient)) - .where(RecipeIngredient.id == ri_id) - ) - ri = ri_result.scalar_one_or_none() - if not ri: - raise HTTPException(404, "Recipe ingredient not found") - - changes = [] - if data.unit is not None: - new_unit = data.unit if data.unit != ri.ingredient.standard_unit else None - if new_unit != ri.unit: - old_display = ri.unit or ri.ingredient.standard_unit - new_display = data.unit or ri.ingredient.standard_unit - if old_display != new_display: - changes.append(f"{ri.ingredient.name} unit changed from {old_display} to {new_display}") - ri.unit = new_unit - if data.quantity is not None and float(ri.quantity) != data.quantity: - old_qty = float(ri.quantity) - display_unit = ri.unit or ri.ingredient.standard_unit - changes.append(f"{ri.ingredient.name} quantity changed from {old_qty} to {data.quantity}{display_unit}") - ri.quantity = Decimal(str(data.quantity)) - if data.yield_percent is not None and float(ri.yield_percent) != data.yield_percent: - old_yld = float(ri.yield_percent) - changes.append(f"{ri.ingredient.name} yield changed from {old_yld}% to {data.yield_percent}%") - ri.yield_percent = Decimal(str(data.yield_percent)) - if data.notes is not None: - ri.notes = data.notes - if data.sort_order is not None: - ri.sort_order = data.sort_order - - if changes: - db.add(RecipeChangeLog( - recipe_id=ri.recipe_id, - change_summary="; ".join(changes), - user_id=user.id, - )) - - await db.commit() - if changes: - await _snapshot_recipe_and_parents(ri.recipe_id, db, f"ingredient_updated: {'; '.join(changes)}") - return {"ok": True} - - -@router.delete("/recipe-ingredients/{ri_id}") -async def remove_recipe_ingredient( - ri_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - ri_result = await db.execute( - select(RecipeIngredient) - .options(selectinload(RecipeIngredient.ingredient)) - .where(RecipeIngredient.id == ri_id) - ) - ri = ri_result.scalar_one_or_none() - if not ri: - raise HTTPException(404, "Recipe ingredient not found") - - recipe_id_for_snap = ri.recipe_id - ing_name = ri.ingredient.name if ri.ingredient else 'unknown' - db.add(RecipeChangeLog( - recipe_id=recipe_id_for_snap, - change_summary=f"Removed {ing_name}", - user_id=user.id, - )) - await db.delete(ri) - await db.commit() - await _snapshot_recipe_and_parents(recipe_id_for_snap, db, f"ingredient_removed: {ing_name}") - return {"ok": True} - - -# ── Sub-recipes ────────────────────────────────────────────────────────────── - -@router.post("/{recipe_id}/sub-recipes") -async def add_sub_recipe( - recipe_id: int, - data: SubRecipeAdd, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - - if data.child_recipe_id == recipe_id: - raise HTTPException(400, "Cannot add recipe as its own sub-recipe") - - # Circular dependency check (max 5 levels) - cycle_check = await db.execute( - text(""" - WITH RECURSIVE ancestors AS ( - SELECT parent_recipe_id, child_recipe_id, 1 AS depth - FROM recipe_sub_recipes WHERE child_recipe_id = :parent_id - UNION ALL - SELECT rsr.parent_recipe_id, rsr.child_recipe_id, a.depth + 1 - FROM recipe_sub_recipes rsr JOIN ancestors a ON rsr.child_recipe_id = a.parent_recipe_id - WHERE a.depth < 5 - ) - SELECT 1 FROM ancestors WHERE parent_recipe_id = :child_id LIMIT 1 - """), - {"parent_id": recipe_id, "child_id": data.child_recipe_id}, - ) - if cycle_check.fetchone(): - raise HTTPException(400, "Adding this sub-recipe would create a circular dependency") - - sr = RecipeSubRecipe( - parent_recipe_id=recipe_id, - child_recipe_id=data.child_recipe_id, - portions_needed=Decimal(str(data.portions_needed)), - portions_needed_unit=data.portions_needed_unit, - notes=data.notes, - sort_order=data.sort_order, - ) - db.add(sr) - - child = await db.execute(select(Recipe).where(Recipe.id == data.child_recipe_id)) - child_recipe = child.scalar_one_or_none() - unit_label = data.portions_needed_unit or _get_output_unit(child_recipe) if child_recipe else "portions" - db.add(RecipeChangeLog( - recipe_id=recipe_id, - change_summary=f"Added sub-recipe '{child_recipe.name if child_recipe else '?'}' ({data.portions_needed} {unit_label})", - user_id=user.id, - )) - - await db.commit() - await db.refresh(sr) - await _snapshot_recipe_and_parents(recipe_id, db, f"sub_recipe_added: {child_recipe.name if child_recipe else '?'}") - return {"id": sr.id} - - -@router.patch("/recipe-sub-recipes/{sr_id}") -async def update_sub_recipe( - sr_id: int, - data: SubRecipeUpdateSchema, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - sr_result = await db.execute( - select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) - ) - sr = sr_result.scalar_one_or_none() - if not sr: - raise HTTPException(404, "Sub-recipe not found") - changed = False - if data.portions_needed is not None: - sr.portions_needed = Decimal(str(data.portions_needed)) - changed = True - if data.portions_needed_unit is not None: - sr.portions_needed_unit = data.portions_needed_unit or None - changed = True - if data.notes is not None: - sr.notes = data.notes - if data.sort_order is not None: - sr.sort_order = data.sort_order - await db.commit() - if changed: - await _snapshot_recipe_and_parents(sr.parent_recipe_id, db, "sub_recipe_qty_updated") - return {"ok": True} - - -@router.delete("/recipe-sub-recipes/{sr_id}") -async def remove_sub_recipe( - sr_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - sr_result = await db.execute( - select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) - ) - sr = sr_result.scalar_one_or_none() - if not sr: - raise HTTPException(404, "Sub-recipe not found") - parent_id = sr.parent_recipe_id - await db.delete(sr) - await db.commit() - await _snapshot_recipe_and_parents(parent_id, db, "sub_recipe_removed") - return {"ok": True} - - -# ── Steps ──────────────────────────────────────────────────────────────────── - -@router.post("/{recipe_id}/steps") -async def add_step( - recipe_id: int, - data: StepCreate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - await _get_recipe(recipe_id, user.kitchen_id, db) - step = RecipeStep( - recipe_id=recipe_id, - step_number=data.step_number, - title=data.title, - instruction=data.instruction, - duration_minutes=data.duration_minutes, - notes=data.notes, - ) - db.add(step) - await db.commit() - await db.refresh(step) - return {"id": step.id} - - -@router.patch("/recipe-steps/{step_id}") -async def update_step( - step_id: int, - data: StepUpdate, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) - step = result.scalar_one_or_none() - if not step: - raise HTTPException(404, "Step not found") - if data.title is not None: - step.title = data.title or None # empty string -> null - if data.instruction is not None: - step.instruction = data.instruction - if data.step_number is not None: - step.step_number = data.step_number - if data.duration_minutes is not None: - step.duration_minutes = data.duration_minutes - if data.notes is not None: - step.notes = data.notes - await db.commit() - return {"ok": True} - - -@router.delete("/recipe-steps/{step_id}") -async def delete_step( - step_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) - step = result.scalar_one_or_none() - if not step: - raise HTTPException(404, "Step not found") - await db.delete(step) - await db.commit() - return {"ok": True} - - -@router.patch("/{recipe_id}/ingredients/reorder") -async def reorder_ingredients( - recipe_id: int, - data: IngredientReorder, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Batch reorder recipe ingredients.""" - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - for i, ri_id in enumerate(data.ingredient_ids): - result = await db.execute( - select(RecipeIngredient).where( - RecipeIngredient.id == ri_id, - RecipeIngredient.recipe_id == recipe_id, - ) - ) - ri = result.scalar_one_or_none() - if ri: - ri.sort_order = i - await db.commit() - return {"ok": True} - - -@router.patch("/{recipe_id}/sub-recipes/reorder") -async def reorder_sub_recipes( - recipe_id: int, - data: SubRecipeReorder, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Batch reorder sub-recipes.""" - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - for i, sr_id in enumerate(data.sub_recipe_ids): - result = await db.execute( - select(RecipeSubRecipe).where( - RecipeSubRecipe.id == sr_id, - RecipeSubRecipe.parent_recipe_id == recipe_id, - ) - ) - sr = result.scalar_one_or_none() - if sr: - sr.sort_order = i - await db.commit() - return {"ok": True} - - -@router.patch("/{recipe_id}/steps/reorder") -async def reorder_steps( - recipe_id: int, - data: StepReorder, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - for i, step_id in enumerate(data.step_ids): - result = await db.execute( - select(RecipeStep).where(RecipeStep.id == step_id, RecipeStep.recipe_id == recipe_id) - ) - step = result.scalar_one_or_none() - if step: - step.step_number = i + 1 - await db.commit() - return {"ok": True} - - -# ── Images ─────────────────────────────────────────────────────────────────── - -@router.post("/{recipe_id}/images") -async def upload_image( - recipe_id: int, - file: UploadFile = File(...), - caption: Optional[str] = Query(None), - image_type: str = Query("general"), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - - # Create directory - img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "recipes") - os.makedirs(img_dir, exist_ok=True) - - ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" - filename = f"{uuid.uuid4()}{ext}" - filepath = os.path.join(img_dir, filename) - - content = await file.read() - with open(filepath, "wb") as f: - f.write(content) - - img = RecipeImage( - recipe_id=recipe_id, - image_path=filepath, - caption=caption, - image_type=image_type, - uploaded_by=user.id, - ) - db.add(img) - await db.commit() - await db.refresh(img) - return {"id": img.id, "image_path": img.image_path} - - -@router.get("/{recipe_id}/images/{image_id}") -async def serve_image( - recipe_id: int, - image_id: int, - token: Optional[str] = Query(None), - db: AsyncSession = Depends(get_db), -): - # img tags can't send Authorization header, so auth via query param - if not token: - raise HTTPException(401, "Not authenticated") - user = await get_current_user_from_token(token, db) - if not user: - raise HTTPException(401, "Not authenticated") - await _get_recipe(recipe_id, user.kitchen_id, db) - result = await db.execute( - select(RecipeImage).where(RecipeImage.id == image_id, RecipeImage.recipe_id == recipe_id) - ) - img = result.scalar_one_or_none() - if not img or not os.path.exists(img.image_path): - raise HTTPException(404, "Image not found") - from fastapi.responses import FileResponse - return FileResponse(img.image_path) - - -@router.delete("/recipe-images/{image_id}") -async def delete_image( - image_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - result = await db.execute(select(RecipeImage).where(RecipeImage.id == image_id)) - img = result.scalar_one_or_none() - if not img: - raise HTTPException(404, "Image not found") - if os.path.exists(img.image_path): - os.remove(img.image_path) - await db.delete(img) - await db.commit() - return {"ok": True} - - -# ── Costing ────────────────────────────────────────────────────────────────── - -def _scale_child_sub_recipes(sub_recipes: list, scale: float) -> list: - """Recursively scale nested sub-recipe data for hierarchical display.""" - result = [] - for sr in sub_recipes: - result.append({ - "child_recipe_id": sr["child_recipe_id"], - "child_recipe_name": sr["child_recipe_name"], - "batch_output_type": sr.get("batch_output_type", "portions"), - "output_qty": sr.get("output_qty", 1), - "output_unit": sr.get("output_unit", "portion"), - "portions_needed": round(sr["portions_needed"] * scale, 4), - "cost_contribution": round(sr["cost_contribution"] * scale, 4) if sr.get("cost_contribution") else None, - "child_ingredients": [ - { - "ingredient_id": ci["ingredient_id"], - "ingredient_name": ci["ingredient_name"], - "quantity": round(ci["quantity"] * scale, 4), - "unit": ci["unit"], - "yield_percent": ci.get("yield_percent", 100.0), - "cost_recent": round(ci["cost_recent"] * scale, 4) if ci.get("cost_recent") else None, - "cost_min": round(ci["cost_min"] * scale, 4) if ci.get("cost_min") else None, - "cost_max": round(ci["cost_max"] * scale, 4) if ci.get("cost_max") else None, - "is_manual_price": ci.get("is_manual_price", False), - "has_no_price": ci.get("has_no_price", False), - } - for ci in sr.get("child_ingredients", []) - ], - "child_sub_recipes": _scale_child_sub_recipes(sr.get("child_sub_recipes", []), scale), - }) - return result - - -async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional[float] = None) -> dict: - """Calculate full cost breakdown for a recipe.""" - recipe_result = await db.execute( - select(Recipe) - .options( - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), - selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), - ) - .where(Recipe.id == recipe_id) - ) - recipe = recipe_result.scalar_one_or_none() - if not recipe: - return {} - - output_qty = _get_output_qty(recipe) - output_unit = _get_output_unit(recipe) - scale_factor = (scale_to / output_qty) if scale_to else 1.0 - - ingredient_costs = [] - total_ing_cost = Decimal(0) - total_ing_cost_min = Decimal(0) - total_ing_cost_max = Decimal(0) - - for ri in recipe.ingredients: - ing = ri.ingredient - if not ing: - continue - - display_unit = ri.unit or ing.standard_unit - qty_display = float(ri.quantity) * scale_factor - # Convert to standard unit for cost calculation - qty_std = _convert_unit(qty_display, display_unit, ing.standard_unit) - yld = float(ri.yield_percent) if ri.yield_percent else 100.0 - - # Get all source prices - source_prices = [] - for src in (ing.sources or []): - if src.price_per_std_unit: - source_prices.append({ - "supplier_id": src.supplier_id, - "price_per_std_unit": float(src.price_per_std_unit), - "latest_invoice_date": str(src.latest_invoice_date) if src.latest_invoice_date else None, - }) - - # Effective prices - recent_price = None - min_price = None - max_price = None - if source_prices: - prices = [sp["price_per_std_unit"] for sp in source_prices] - min_price = min(prices) - max_price = max(prices) - recent_price = source_prices[0]["price_per_std_unit"] - # Find most recent by date - dated = [(sp.get("latest_invoice_date", ""), sp["price_per_std_unit"]) for sp in source_prices] - dated.sort(reverse=True) - if dated: - recent_price = dated[0][1] - elif ing.manual_price: - recent_price = min_price = max_price = float(ing.manual_price) - - ing_is_free = getattr(ing, 'is_free', False) - is_manual_price = (not source_prices and ing.manual_price is not None) if not ing_is_free else False - has_no_price = (recent_price is None) if not ing_is_free else False - - # Apply yield adjustment - if recent_price and yld > 0: - recent_eff = recent_price / (yld / 100) - else: - recent_eff = recent_price - if min_price and yld > 0: - min_eff = min_price / (yld / 100) - else: - min_eff = min_price - if max_price and yld > 0: - max_eff = max_price / (yld / 100) - else: - max_eff = max_price - - # Cost is calculated in standard units - cost_recent = round(qty_std * recent_eff, 4) if recent_eff else None - cost_min = round(qty_std * min_eff, 4) if min_eff else None - cost_max = round(qty_std * max_eff, 4) if max_eff else None - - if cost_recent: - total_ing_cost += Decimal(str(cost_recent)) - if cost_min: - total_ing_cost_min += Decimal(str(cost_min)) - if cost_max: - total_ing_cost_max += Decimal(str(cost_max)) - - ingredient_costs.append({ - "ingredient_id": ing.id, - "ingredient_name": ing.name, - "quantity": qty_display, - "unit": display_unit, - "yield_percent": yld, - "recent_price": round(recent_eff, 6) if recent_eff else None, - "min_price": round(min_eff, 6) if min_eff else None, - "max_price": round(max_eff, 6) if max_eff else None, - "cost_recent": cost_recent, - "cost_min": cost_min, - "cost_max": cost_max, - "sources": source_prices, - "is_manual_price": is_manual_price, - "has_no_price": has_no_price, - }) - - # Sub-recipe costs - sub_recipe_costs = [] - total_sub_cost = Decimal(0) - total_sub_cost_min = Decimal(0) - total_sub_cost_max = Decimal(0) - - for sr in recipe.sub_recipes: - child = sr.child_recipe - if not child: - continue - child_cost_data = await _calc_recipe_cost(child.id, db) - child_total = Decimal(str(child_cost_data.get("total_cost_recent", 0) or 0)) - child_total_min = Decimal(str(child_cost_data.get("total_cost_min", 0) or 0)) - child_total_max = Decimal(str(child_cost_data.get("total_cost_max", 0) or 0)) - child_output_qty = _get_output_qty(child) - child_output_unit = _get_output_unit(child) - # Convert portions_needed to child output unit if different unit was used - needed_unit = sr.portions_needed_unit or child_output_unit - portions_needed_raw = float(sr.portions_needed) * scale_factor - portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit) - scale_ratio = portions_needed / child_output_qty if child_output_qty else 0 - cost_contribution = float(child_total) * scale_ratio if child_total else None - cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None - cost_contribution_max = float(child_total_max) * scale_ratio if child_total_max else None - - if cost_contribution: - total_sub_cost += Decimal(str(cost_contribution)) - if cost_contribution_min: - total_sub_cost_min += Decimal(str(cost_contribution_min)) - if cost_contribution_max: - total_sub_cost_max += Decimal(str(cost_contribution_max)) - - # Scale child ingredients by portions_needed / child_output_qty - # Include both direct ingredients AND ingredients from the child's own sub-recipes - child_scale = portions_needed / child_output_qty if child_output_qty else 1 - child_ingredients = [] - for ci in child_cost_data.get("ingredients", []): - child_ingredients.append({ - "ingredient_id": ci["ingredient_id"], - "ingredient_name": ci["ingredient_name"], - "quantity": round(ci["quantity"] * child_scale, 4), - "unit": ci["unit"], - "yield_percent": ci["yield_percent"], - "cost_recent": round(ci["cost_recent"] * child_scale, 4) if ci.get("cost_recent") else None, - "cost_min": round(ci["cost_min"] * child_scale, 4) if ci.get("cost_min") else None, - "cost_max": round(ci["cost_max"] * child_scale, 4) if ci.get("cost_max") else None, - "is_manual_price": ci.get("is_manual_price", False), - "has_no_price": ci.get("has_no_price", False), - }) - # Build hierarchical child_sub_recipes from the child's own sub-recipes - child_sub_recipes = _scale_child_sub_recipes( - child_cost_data.get("sub_recipes", []), child_scale - ) - - sub_recipe_costs.append({ - "child_recipe_id": child.id, - "child_recipe_name": child.name, - "batch_portions": child.batch_portions, - "batch_output_type": child.batch_output_type or "portions", - "output_qty": child_output_qty, - "output_unit": child_output_unit, - "portions_needed": portions_needed, - "cost_per_portion": float(child_total) / child_output_qty if child_total and child_output_qty else None, - "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, - "child_ingredients": child_ingredients, - "child_sub_recipes": child_sub_recipes, - }) - - total_cost = float(total_ing_cost + total_sub_cost) - total_cost_min = float(total_ing_cost_min + total_sub_cost_min) - total_cost_max = float(total_ing_cost_max + total_sub_cost_max) - effective_output = scale_to if scale_to else output_qty - cost_per_portion = total_cost / effective_output if effective_output and total_cost else None - - # GP calculator for dishes (gross prices incl. 20% VAT) - gp_comparison = None - if recipe.recipe_type == "dish" and cost_per_portion: - vat_rate = 1.20 - gp_comparison = [ - {"gp_target": pct, "suggested_price": round(cost_per_portion / (1 - pct / 100) * vat_rate, 2)} - for pct in [60, 65, 70, 75, 80] - ] - - return { - "recipe_id": recipe.id, - "batch_portions": recipe.batch_portions or 1, - "batch_output_type": recipe.batch_output_type or "portions", - "output_qty": effective_output, - "output_unit": output_unit, - "ingredients": ingredient_costs, - "sub_recipes": sub_recipe_costs, - "total_cost_recent": round(total_cost, 4) if total_cost else None, - "total_cost_min": round(total_cost_min, 4) if total_cost_min else None, - "total_cost_max": round(total_cost_max, 4) if total_cost_max else None, - "cost_per_portion": round(cost_per_portion, 4) if cost_per_portion else None, - "gp_comparison": gp_comparison, - } - - -@router.get("/{recipe_id}/costing") -async def get_recipe_costing( - recipe_id: int, - scale_to: Optional[float] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - return await _calc_recipe_cost(recipe_id, db, scale_to) - - -@router.get("/{recipe_id}/cost-trend") -async def get_cost_trend( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - await _get_recipe(recipe_id, user.kitchen_id, db) - result = await db.execute( - select(RecipeCostSnapshot) - .where(RecipeCostSnapshot.recipe_id == recipe_id) - .order_by(RecipeCostSnapshot.snapshot_date) - ) - snapshots = result.scalars().all() - - # Get change log entries to show what caused cost changes - log_result = await db.execute( - select(RecipeChangeLog) - .where(RecipeChangeLog.recipe_id == recipe_id) - .order_by(RecipeChangeLog.created_at) - ) - logs = log_result.scalars().all() - - # Group changes by date for annotation - changes_by_date: dict[str, list[str]] = {} - for log in logs: - if log.created_at: - log_date = str(log.created_at.date()) if hasattr(log.created_at, 'date') else str(log.created_at)[:10] - changes_by_date.setdefault(log_date, []).append(log.change_summary) - - return { - "snapshots": [ - { - "id": s.id, - "created_at": str(s.snapshot_date), - "cost_per_portion": float(s.cost_per_portion), - "total_cost": float(s.total_cost), - "trigger": s.trigger_source or "", - "changes": changes_by_date.get(str(s.snapshot_date), []), - } - for s in snapshots - ], - } - - -async def snapshot_recipe_cost(recipe_id: int, db: AsyncSession, trigger_source: str = "manual_recalc"): - """Calculate and store/upsert a cost snapshot for today.""" - cost_data = await _calc_recipe_cost(recipe_id, db) - total_cost = cost_data.get("total_cost_recent") - cost_per_portion = cost_data.get("cost_per_portion") - if total_cost is None or cost_per_portion is None: - return - - today = date.today() - existing = await db.execute( - select(RecipeCostSnapshot).where( - RecipeCostSnapshot.recipe_id == recipe_id, - RecipeCostSnapshot.snapshot_date == today, - ) - ) - snap = existing.scalar_one_or_none() - if snap: - snap.cost_per_portion = Decimal(str(cost_per_portion)) - snap.total_cost = Decimal(str(total_cost)) - snap.trigger_source = trigger_source - else: - db.add(RecipeCostSnapshot( - recipe_id=recipe_id, - cost_per_portion=Decimal(str(cost_per_portion)), - total_cost=Decimal(str(total_cost)), - snapshot_date=today, - trigger_source=trigger_source, - )) - - -async def _snapshot_recipe_and_parents(recipe_id: int, db: AsyncSession, trigger_source: str = ""): - """Snapshot a recipe and any parent recipes that use it as a sub-recipe. - Also bumps updated_at so menu staleness detection picks up content changes.""" - # Bump updated_at on the recipe itself (ingredient/sub-recipe changes don't trigger onupdate) - recipe_result = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) - recipe = recipe_result.scalar_one_or_none() - if recipe: - recipe.updated_at = datetime.utcnow() - - await snapshot_recipe_cost(recipe_id, db, trigger_source) - parent_result = await db.execute( - select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == recipe_id) - ) - for (parent_id,) in parent_result.fetchall(): - # Bump parent updated_at too - p_result = await db.execute(select(Recipe).where(Recipe.id == parent_id)) - parent = p_result.scalar_one_or_none() - if parent: - parent.updated_at = datetime.utcnow() - await snapshot_recipe_cost(parent_id, db, trigger_source) - await db.commit() - - -async def snapshot_recipes_using_ingredient( - ingredient_id: int, - db: AsyncSession, - trigger_source: str = "", - price_info: dict | None = None, - invoice_id: int | None = None, -): - """Find all recipes using this ingredient and snapshot their costs. - - If price_info is provided ({"name", "unit", "old_price", "new_price"}), - a RecipeChangeLog entry is created for each affected recipe. - """ - # Direct usage - ri_result = await db.execute( - select(RecipeIngredient.recipe_id).where(RecipeIngredient.ingredient_id == ingredient_id) - ) - recipe_ids = set(r[0] for r in ri_result.fetchall()) - - # Also find recipes that use sub-recipes containing this ingredient (1 level) - for rid in list(recipe_ids): - parent_result = await db.execute( - select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == rid) - ) - for (parent_id,) in parent_result.fetchall(): - recipe_ids.add(parent_id) - - # Build change log message if price info available - change_msg = None - if price_info: - name = price_info["name"] - unit = price_info.get("unit", "") - old_p = price_info.get("old_price") - new_p = price_info["new_price"] - unit_label = f"/{unit}" if unit else "" - # Only log if price actually changed - if old_p is not None and abs(new_p - old_p) > 0.000001: - change_msg = f"{name} price changed: £{old_p:.4f}{unit_label} → £{new_p:.4f}{unit_label}" - elif old_p is None: - change_msg = f"{name} price set: £{new_p:.4f}{unit_label}" - - for rid in recipe_ids: - # Bump updated_at so menu staleness detection picks up ingredient flag changes - r_result = await db.execute(select(Recipe).where(Recipe.id == rid)) - r = r_result.scalar_one_or_none() - if r: - r.updated_at = datetime.utcnow() - await snapshot_recipe_cost(rid, db, trigger_source) - if change_msg: - db.add(RecipeChangeLog( - recipe_id=rid, - change_summary=change_msg, - user_id=None, - source_invoice_id=invoice_id, - )) - if recipe_ids: - await db.commit() - - -# ── Recipe Change Log ──────────────────────────────────────────────────────── - -@router.get("/{recipe_id}/change-log") -async def get_change_log( - recipe_id: int, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - await _get_recipe(recipe_id, user.kitchen_id, db) - result = await db.execute( - select(RecipeChangeLog) - .options(selectinload(RecipeChangeLog.user)) - .where(RecipeChangeLog.recipe_id == recipe_id) - .order_by(RecipeChangeLog.created_at.desc()) - ) - logs = result.scalars().all() - return [ - { - "id": l.id, - "change_summary": l.change_summary, - "username": l.user.name if l.user else "", - "created_at": str(l.created_at) if l.created_at else "", - } - for l in logs - ] - - -@router.post("/cleanup-false-price-changes") -async def cleanup_false_price_changes( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Remove recipe change log entries where old price equals new price (false changes).""" - if not user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - import re - - # Get all "price changed" log entries for this kitchen's recipes - result = await db.execute( - select(RecipeChangeLog) - .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) - .where( - Recipe.kitchen_id == user.kitchen_id, - RecipeChangeLog.change_summary.like("%price changed%"), - ) - ) - logs = result.scalars().all() - - # Pattern: "Ingredient name price changed: £0.0086/g → £0.0086/g" - pattern = re.compile(r"price changed: £([\d.]+)(/\w+)? → £([\d.]+)(/\w+)?") - deleted = 0 - for log in logs: - match = pattern.search(log.change_summary) - if match: - old_price = float(match.group(1)) - new_price = float(match.group(3)) - if abs(old_price - new_price) < 0.000001: - await db.delete(log) - deleted += 1 - - await db.commit() - return {"message": f"Removed {deleted} false price change entries", "deleted": deleted} - - -@router.post("/backfill-invoice-references") -async def backfill_invoice_references( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Backfill missing source_invoice_id on recipe change log entries - by matching to cost snapshots whose trigger_source contains 'invoice #'.""" - if not user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - import re - - # Get change log entries missing source_invoice_id for this kitchen - result = await db.execute( - select(RecipeChangeLog) - .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) - .where( - Recipe.kitchen_id == user.kitchen_id, - RecipeChangeLog.source_invoice_id == None, - (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), - ) - ) - logs = result.scalars().all() - - if not logs: - return {"message": "No entries missing invoice references", "updated": 0} - - # For each log, find a cost snapshot for same recipe created within 5 seconds - invoice_re = re.compile(r"invoice #(\d+)") - updated = 0 - for log in logs: - if not log.created_at: - continue - # Look for a cost snapshot close in time with an invoice trigger_source - snap_result = await db.execute( - select(RecipeCostSnapshot.trigger_source).where( - RecipeCostSnapshot.recipe_id == log.recipe_id, - RecipeCostSnapshot.created_at.between( - log.created_at - timedelta(seconds=5), - log.created_at + timedelta(seconds=5), - ), - RecipeCostSnapshot.trigger_source.like("%invoice #%"), - ).limit(1) - ) - row = snap_result.scalar_one_or_none() - if row: - m = invoice_re.search(row) - if m: - log.source_invoice_id = int(m.group(1)) - updated += 1 - - await db.commit() - return {"message": f"Linked {updated} of {len(logs)} entries to their triggering invoice", "updated": updated, "total": len(logs)} - - -# ── Print / Recipe Card HTML ──────────────────────────────────────────────── - -@router.get("/{recipe_id}/print") -async def print_recipe( - recipe_id: int, - format: str = Query("full"), # "full" | "kitchen" - token: Optional[str] = Query(None), - db: AsyncSession = Depends(get_db), -): - # window.open() can't send Authorization header, so auth via query param - if not token: - raise HTTPException(status_code=401, detail="Not authenticated") - user = await get_current_user_from_token(token, db) - if not user: - raise HTTPException(status_code=401, detail="Not authenticated") - recipe = await _get_recipe(recipe_id, user.kitchen_id, db) - full_data = await get_recipe(recipe_id, user, db) - cost_data = await _calc_recipe_cost(recipe_id, db) - - # Get flags - from api.food_flags import compute_recipe_flags - flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) - - html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token) - return HTMLResponse(content=html) - - -def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str: - """Generate print-optimised HTML for a recipe.""" - esc = html_escape - name = esc(recipe_data.get("name", "")) - recipe_type = recipe_data.get("recipe_type", "dish") - batch = recipe_data.get("batch_portions", 1) - batch_output_type = recipe_data.get("batch_output_type", "portions") - output_qty = recipe_data.get("output_qty", batch) - output_unit = recipe_data.get("output_unit", "portion") - description = esc(recipe_data.get("description", "") or "") - prep_time = recipe_data.get("prep_time_minutes") - cook_time = recipe_data.get("cook_time_minutes") - - # Flag badges - flag_html = "" - for f in flags: - if f.is_active: - color = "#dc3545" if f.propagation_type == "contains" else "#28a745" - badge_style = f"display:inline-block;padding:2px 8px;margin:2px;border-radius:12px;background:{color};color:white;font-size:12px;" - if f.excludable_on_request: - badge_style += "border:2px dashed white;" - flag_html += f'{esc(f.flag_code or f.flag_name)}' - - # Ingredients table - ing_rows = "" - for ing in recipe_data.get("ingredients", []): - cost_str = f"£{ing['cost']:.2f}" if ing.get("cost") else "-" - ing_rows += f""" - {esc(ing['ingredient_name'])} - {ing['quantity']:g}{esc(ing['unit'])} - {cost_str} - """ - - # Sub-recipes — match cost_data child_ingredients by child_recipe_id - cost_sub_recipes = {sr["child_recipe_id"]: sr for sr in cost_data.get("sub_recipes", [])} - sub_rows = "" - for sr in recipe_data.get("sub_recipes", []): - cost_sr = cost_sub_recipes.get(sr["child_recipe_id"], {}) - cost_str = f"£{sr['cost_contribution']:.2f}" if sr.get("cost_contribution") else "-" - sr_unit = sr.get("output_unit", "portion") - sr_unit_label = f"{sr['portions_needed']:g} {sr_unit}{'s' if sr['portions_needed'] != 1 and sr_unit == 'portion' else ''}" - sub_rows += f""" - ▸ {esc(sr['child_recipe_name'])} ({sr_unit_label}) - - {cost_str} - """ - for ci in cost_sr.get("child_ingredients", []): - ci_cost = f"£{ci['cost_recent']:.2f}" if ci.get("cost_recent") else "-" - sub_rows += f""" - ↳ {esc(ci['ingredient_name'])} - {ci['quantity']:g}{esc(ci['unit'])} - {ci_cost} - """ - - # Steps - steps_html = "" - for step in recipe_data.get("steps", []): - dur = f" ({step['duration_minutes']} min)" if step.get("duration_minutes") else "" - title = esc(step['title']) if step.get('title') else None - instr = esc(step['instruction']) - if title: - steps_html += f"
  • {title}{dur}
    {instr}
  • " - else: - steps_html += f"
  • {instr}{f' {dur}' if dur else ''}
  • " - - # Cost summary - cost_per_portion = cost_data.get("cost_per_portion") - total_cost = cost_data.get("total_cost_recent") - cost_summary = "" - if format == "full" and cost_per_portion: - cost_unit_label = f"Cost per {output_unit}" if output_unit != "portion" else "Cost per portion" - cost_summary = f""" -
    - {cost_unit_label}: £{cost_per_portion:.4f} | - Total cost: £{total_cost:.2f} -
    """ - - # Images (plating photos for kitchen card) - images_html = "" - if format == "kitchen": - plating_images = [img for img in recipe_data.get("images", []) if img.get("image_type") == "plating"] - if not plating_images: - plating_images = recipe_data.get("images", [])[:1] - for img in plating_images: - img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}" - images_html += f'' - - time_info = "" - if prep_time or cook_time: - parts = [] - if prep_time: - parts.append(f"Prep: {prep_time} min") - if cook_time: - parts.append(f"Cook: {cook_time} min") - time_info = f"

    {' | '.join(parts)}

    " - - kitchen_font_size = "font-size:16px;" if format == "kitchen" else "" - - return f""" - - - - {name} - - - -

    {name}

    -

    - {recipe_type.upper()} - {f'Yield: {output_qty:g}{output_unit}' if recipe_type == 'component' and batch_output_type == 'bulk' else (f'Batch: {batch} portions' if recipe_type == 'component' else '')} -

    - {time_info} - {f'

    {description}

    ' if description else ''} -
    {flag_html}
    - {images_html} - -

    Ingredients

    - - - - - - - {ing_rows}{sub_rows} -
    IngredientQuantityCost
    - - {cost_summary} - - {'

    Method

      ' + steps_html + '
    ' if steps_html else ''} - - {f'

    Printed {date.today().strftime("%d/%m/%Y")}

    '} - -""" - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -# Unit conversion factors to a common base (g for weight, ml for volume) -_UNIT_TO_BASE = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} - -# Which units are compatible (same measurement type) -_COMPATIBLE_UNITS = { - "g": ("g", "kg"), "kg": ("g", "kg"), - "ml": ("ml", "ltr"), "ltr": ("ml", "ltr"), - "portion": ("portion",), "each": ("each",), -} - - -def _convert_unit(value: float, from_unit: str, to_unit: str) -> float: - """Convert a value between compatible units. Returns original value if incompatible.""" - if from_unit == to_unit: - return value - from_base = _UNIT_TO_BASE.get(from_unit) - to_base = _UNIT_TO_BASE.get(to_unit) - if from_base is None or to_base is None: - return value - # Check compatibility - if to_unit not in _COMPATIBLE_UNITS.get(from_unit, ()): - return value - return value * from_base / to_base - - -def _get_compatible_units(unit: str) -> list[str]: - """Get list of compatible units for a given output unit.""" - return list(_COMPATIBLE_UNITS.get(unit, (unit,))) - - -def _get_output_qty(recipe) -> float: - """Unified output quantity: bulk uses yield_qty, portioned uses batch_portions.""" - if recipe.batch_output_type == "bulk" and recipe.batch_yield_qty: - return float(recipe.batch_yield_qty) - return recipe.batch_portions or 1 - -def _get_output_unit(recipe) -> str: - """Unified output unit label: bulk uses yield_unit, portioned uses 'portion'.""" - if recipe.batch_output_type == "bulk" and recipe.batch_yield_unit: - return recipe.batch_yield_unit - return "portion" - -async def _get_recipe(recipe_id: int, kitchen_id: int, db: AsyncSession) -> Recipe: - result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == kitchen_id) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - return recipe diff --git a/backend/api/reconciliation.py b/backend/api/reconciliation.py deleted file mode 100644 index 5948012..0000000 --- a/backend/api/reconciliation.py +++ /dev/null @@ -1,1187 +0,0 @@ -""" -Xero ↔ Flash Purchases Reconciliation - -Accepts a Xero "Account Transactions" XLSX export, parses it, -matches rows against Flash invoices for the same period, and -returns a four-section reconciliation report. - -Matching is rule-based first (exact → near-match), with an -optional LLM pass for remaining unmatched items. -""" -from datetime import date, timedelta -from decimal import Decimal, InvalidOperation -from typing import Optional -import io -import re -import logging -import json - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.invoice import Invoice, InvoiceStatus -from models.supplier import Supplier -from auth import get_current_user, require_cap - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# ============ Pydantic Response Models ============ - -class ReconcileMatchedItem(BaseModel): - date: str - supplier: str - ref: str - amount: str # formatted £ - flash_id: int | None = None - match_source: str = "rule" # "rule" or "ai" - -class ReconcileDiscrepancyItem(BaseModel): - date: str - supplier: str - ref: str - flash_amount: str - xero_amount: str - difference: str - differs: list[str] # which fields differ: "amount", "date", "ref" - flash_id: int | None = None - flash_date: str | None = None - xero_date: str | None = None - flash_ref: str | None = None - xero_ref: str | None = None - flash_supplier: str | None = None - xero_description: str | None = None - match_source: str = "rule" # "rule" or "ai" - amount_insight: str | None = None # explanation for amount discrepancy - -class ReconcileUnmatchedFlash(BaseModel): - date: str - supplier: str - ref: str - net_stock: str - flash_id: int - -class ReconcileUnmatchedXero(BaseModel): - date: str - description: str - ref: str - net: str - is_expected_external: bool = False - -class ReconcileResponse(BaseModel): - period_start: str - period_end: str - flash_total: str - xero_total: str - difference: str - matched_count: int - discrepancy_count: int - flash_only_count: int - xero_only_count: int - non_stock_excluded_count: int - non_stock_excluded_total: str - matched: list[ReconcileMatchedItem] - discrepancies: list[ReconcileDiscrepancyItem] - flash_only: list[ReconcileUnmatchedFlash] - xero_only: list[ReconcileUnmatchedXero] - llm_matches_attempted: bool = False - - -# ============ Supplier alias map for Xero description → Flash supplier ============ - -# Xero descriptions often look like "Supplier Name - food" or "Supplier - food crn" -# This maps known Xero description variants to the Flash supplier name -SUPPLIER_ALIASES = { - "j hall & son (bakers) ltd": "Halls", - "j hall & son bakers ltd": "Halls", - "halls": "Halls", - "r & d walker ltd": "R&D Walker", - "r&d walker ltd": "R&D Walker", - "r&d walker": "R&D Walker", - "lambournes of stow-on-the-wold": "Lambournes", - "lambournes": "Lambournes", - "bramleys": "Bramleys", - "cotswold coffee": "Cotswold Coffee", - "brakes": "Brakes", - "direct seafoods": "Direct Seafoods", -} - -# Xero-only entries that are expected (not from Flash) — collapsible sub-section -EXPECTED_EXTERNAL_PATTERNS = [ - "tesco", - "revenue jv", - "journal", - "internal", - "petty", -] - - -# ============ Helpers ============ - -def normalise_ref(ref: str | None) -> str: - """Normalise a reference for comparison: uppercase, strip whitespace/leading #, collapse spaces.""" - if not ref: - return "" - r = ref.strip().upper() - r = r.lstrip("#") - r = re.sub(r"\s+", " ", r).strip() - return r - - -def extract_supplier_from_xero_desc(description: str) -> str: - """ - Extract supplier name from Xero description. - Xero format: "Supplier Name - food", "Supplier - food crn", etc. - """ - desc = description.strip() - # Strip trailing " - food", " - food crn", " - beverage", etc. - desc = re.sub(r"\s*-\s*(food|beverage|cleaning|sundry)(\s+crn)?\s*$", "", desc, flags=re.IGNORECASE) - return desc.strip() - - -def normalise_supplier(name: str) -> str: - """Normalise supplier name to lowercase for comparison.""" - return name.strip().lower() - - -def resolve_supplier(xero_desc: str) -> str: - """Resolve a Xero description to a canonical supplier name.""" - extracted = extract_supplier_from_xero_desc(xero_desc) - norm = normalise_supplier(extracted) - if norm in SUPPLIER_ALIASES: - return SUPPLIER_ALIASES[norm] - return extracted - - -def is_expected_external(xero_desc: str) -> bool: - """Check if a Xero entry is expected to be outside Flash.""" - desc_lower = xero_desc.lower() - return any(pat in desc_lower for pat in EXPECTED_EXTERNAL_PATTERNS) - - -def levenshtein(s1: str, s2: str) -> int: - """Compute Levenshtein distance between two strings.""" - if len(s1) < len(s2): - return levenshtein(s2, s1) - if len(s2) == 0: - return len(s1) - prev_row = list(range(len(s2) + 1)) - for i, c1 in enumerate(s1): - curr_row = [i + 1] - for j, c2 in enumerate(s2): - insertions = prev_row[j + 1] + 1 - deletions = curr_row[j] + 1 - substitutions = prev_row[j] + (c1 != c2) - curr_row.append(min(insertions, deletions, substitutions)) - prev_row = curr_row - return prev_row[-1] - - -def alphanumeric_only(s: str) -> str: - """Strip non-alphanumeric characters for fuzzy ref comparison.""" - return re.sub(r"[^A-Z0-9]", "", s.upper()) - - -def amounts_match(a: Decimal, b: Decimal, tolerance: Decimal = Decimal("0.01")) -> bool: - return abs(a - b) <= tolerance - - -def fmt_money(d: Decimal) -> str: - """Format a decimal as £X,XXX.XX""" - return f"£{d:,.2f}" - - -def parse_xero_date(val) -> date | None: - """Parse a date from an openpyxl cell value.""" - from datetime import datetime - if isinstance(val, datetime): - return val.date() - if isinstance(val, date): - return val - if isinstance(val, str): - val = val.strip() - for fmt in ("%d %b %Y", "%d/%m/%Y", "%Y-%m-%d", "%d %B %Y"): - try: - return datetime.strptime(val, fmt).date() - except ValueError: - continue - return None - - -def parse_xero_amount(val) -> Decimal: - """Parse a numeric cell to Decimal, returning 0 for None/empty.""" - if val is None: - return Decimal("0") - if isinstance(val, (int, float)): - return Decimal(str(val)).quantize(Decimal("0.01")) - if isinstance(val, str): - val = val.strip().replace(",", "").replace("£", "") - if not val or val == "-": - return Decimal("0") - try: - return Decimal(val).quantize(Decimal("0.01")) - except InvalidOperation: - return Decimal("0") - return Decimal("0") - - -# ============ XLSX Parsing ============ - -def parse_xero_xlsx(file_bytes: bytes) -> tuple[date, date, list[dict]]: - """ - Parse a Xero Account Transactions XLSX export. - - Returns (period_start, period_end, rows) where each row is: - { - "date": date, - "description": str, - "ref": str, - "debit": Decimal, - "credit": Decimal, - "net": Decimal, # debit - credit - } - """ - from openpyxl import load_workbook - - wb = load_workbook(filename=io.BytesIO(file_bytes), read_only=True, data_only=True) - ws = wb.active - - # Extract period from header rows (typically row 3) - period_start = None - period_end = None - for row_idx in range(1, 6): - cell_val = ws.cell(row=row_idx, column=1).value - if cell_val and isinstance(cell_val, str) and "period" in cell_val.lower(): - # "For the period 1 March 2026 to 31 March 2026" - match = re.search( - r"(\d{1,2}\s+\w+\s+\d{4})\s+to\s+(\d{1,2}\s+\w+\s+\d{4})", - cell_val, re.IGNORECASE - ) - if match: - from datetime import datetime - for fmt in ("%d %B %Y", "%d %b %Y"): - try: - period_start = datetime.strptime(match.group(1), fmt).date() - period_end = datetime.strptime(match.group(2), fmt).date() - break - except ValueError: - continue - break - - if not period_start or not period_end: - raise ValueError("Could not extract report period from XLSX header rows. Expected 'For the period DD Month YYYY to DD Month YYYY' in rows 1-5.") - - # Find header row (look for "Date" in column A) - header_row = None - for row_idx in range(1, 10): - cell_val = ws.cell(row=row_idx, column=1).value - if cell_val and isinstance(cell_val, str) and cell_val.strip().lower() == "date": - header_row = row_idx - break - - if not header_row: - raise ValueError("Could not find column header row (expected 'Date' in column A within first 10 rows).") - - # Map column headers - col_map = {} - for col_idx in range(1, 20): - val = ws.cell(row=header_row, column=col_idx).value - if val and isinstance(val, str): - col_map[val.strip().lower()] = col_idx - - required = ["date", "description", "debit", "credit"] - for req in required: - if req not in col_map: - raise ValueError(f"Missing required column '{req}' in header row {header_row}. Found: {list(col_map.keys())}") - - ref_col = col_map.get("reference", col_map.get("ref")) - source_col = col_map.get("source") - - # Parse data rows - rows = [] - for row_idx in range(header_row + 1, ws.max_row + 1): - date_val = ws.cell(row=row_idx, column=col_map["date"]).value - parsed_date = parse_xero_date(date_val) - if not parsed_date: - continue # Skip non-data rows (section headers, totals, blanks) - - description = ws.cell(row=row_idx, column=col_map["description"]).value or "" - ref = "" - if ref_col: - ref = ws.cell(row=row_idx, column=ref_col).value or "" - ref = str(ref).strip() - - source = "" - if source_col: - source = str(ws.cell(row=row_idx, column=source_col).value or "").strip() - - debit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["debit"]).value) - credit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["credit"]).value) - net = debit - credit # Credits (credit notes) become negative - - is_credit_note = "credit note" in source.lower() or credit > 0 - - rows.append({ - "date": parsed_date, - "description": str(description).strip(), - "ref": ref, - "source": source, - "debit": debit, - "credit": credit, - "net": net, - "is_credit_note": is_credit_note, - }) - - wb.close() - return period_start, period_end, rows - - -def group_xero_by_ref(rows: list[dict]) -> list[dict]: - """ - Group Xero rows by reference (Xero sometimes splits an invoice across - stock/non-stock lines with the same ref). Sum the nets. - - Rows with empty refs are NOT grouped — each stays separate. - """ - grouped = {} - no_ref = [] - - for row in rows: - norm = normalise_ref(row["ref"]) - if not norm: - no_ref.append({ - "date": row["date"], - "description": row["description"], - "ref": row["ref"], - "net": row["net"], - "is_credit_note": row.get("is_credit_note", False), - "raw_rows": [row], - }) - else: - if norm not in grouped: - grouped[norm] = { - "date": row["date"], - "description": row["description"], - "ref": row["ref"], # keep original formatting from first row - "net": Decimal("0"), - "is_credit_note": row.get("is_credit_note", False), - "raw_rows": [], - } - grouped[norm]["net"] += row["net"] - grouped[norm]["raw_rows"].append(row) - # If any row is a credit note, mark the group - if row.get("is_credit_note"): - grouped[norm]["is_credit_note"] = True - - return list(grouped.values()) + no_ref - - -# ============ Flash Invoice Querying ============ - -async def get_flash_invoices( - db: AsyncSession, - kitchen_id: int, - period_start: date, - period_end: date, -) -> tuple[list[dict], int, Decimal]: - """ - Get Flash invoices for the period. Returns: - - stock_invoices: list of dicts for matching - - non_stock_count: count of excluded non-stock-only invoices - - non_stock_total: total of excluded non-stock amounts - - Each stock invoice dict has: - { - "id": int, - "date": date, - "supplier_name": str, - "ref": str (invoice_number), - "net_stock": Decimal, - "document_type": str, - } - """ - from models.line_item import LineItem - - result = await db.execute( - select(Invoice) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.status == InvoiceStatus.CONFIRMED, - ) - .options(selectinload(Invoice.line_items)) - .order_by(Invoice.invoice_date.desc().nullslast()) - ) - all_invoices = result.scalars().all() - - # Get supplier map - supplier_result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == kitchen_id) - ) - suppliers_map = {s.id: s for s in supplier_result.scalars().all()} - - stock_invoices = [] - non_stock_count = 0 - non_stock_total = Decimal("0") - - for inv in all_invoices: - inv_date = inv.invoice_date or inv.created_at.date() - if not (period_start <= inv_date <= period_end): - continue - - # Calculate net_stock (stock items only) - net_stock = Decimal("0") - net_non_stock = Decimal("0") - if inv.line_items: - for item in inv.line_items: - item_net = item.amount or Decimal("0") - if item.is_non_stock: - net_non_stock += item_net - else: - net_stock += item_net - - # Credit notes: negate if positive - if inv.document_type == "credit_note": - if net_stock > 0: - net_stock = -net_stock - if net_non_stock > 0: - net_non_stock = -net_non_stock - - # Get supplier name - supplier_name = "" - if inv.supplier_id and inv.supplier_id in suppliers_map: - supplier_name = suppliers_map[inv.supplier_id].name - elif inv.vendor_name: - supplier_name = inv.vendor_name - else: - supplier_name = "Unknown" - - if net_stock == 0 and net_non_stock != 0: - # Entirely non-stock — exclude from match pool - non_stock_count += 1 - non_stock_total += net_non_stock - continue - - if net_stock == 0 and net_non_stock == 0: - # Zero-value invoice — nothing to reconcile - continue - - # Build line item summaries for insight generation - line_summaries = [] - if inv.line_items: - for item in inv.line_items: - line_summaries.append({ - "description": item.description or "", - "amount": str(item.amount or 0), - "is_non_stock": item.is_non_stock, - }) - - stock_invoices.append({ - "id": inv.id, - "date": inv_date, - "supplier_name": supplier_name, - "ref": inv.invoice_number or "", - "net_stock": net_stock.quantize(Decimal("0.01")), - "net_non_stock": net_non_stock.quantize(Decimal("0.01")), - "document_type": inv.document_type or "invoice", - "line_items": line_summaries, - }) - - # Track non-stock portion of mixed invoices separately - if net_non_stock != 0: - non_stock_count += 1 - non_stock_total += net_non_stock - - return stock_invoices, non_stock_count, non_stock_total.quantize(Decimal("0.01")) - - -# ============ Build supplier alias map from DB ============ - -async def build_supplier_alias_map(db: AsyncSession, kitchen_id: int) -> dict[str, str]: - """ - Build a normalised-name → canonical-name map from the Supplier table aliases. - Merges with the hardcoded SUPPLIER_ALIASES. - """ - alias_map = dict(SUPPLIER_ALIASES) # start with hardcoded - - result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == kitchen_id) - ) - suppliers = result.scalars().all() - - for s in suppliers: - # Map canonical name - alias_map[normalise_supplier(s.name)] = s.name - # Map aliases - if s.aliases: - for alias in s.aliases: - alias_map[normalise_supplier(alias)] = s.name - - return alias_map - - -def resolve_supplier_with_map(xero_desc: str, alias_map: dict[str, str]) -> str: - """Resolve a Xero description to a canonical supplier name using dynamic alias map.""" - extracted = extract_supplier_from_xero_desc(xero_desc) - norm = normalise_supplier(extracted) - if norm in alias_map: - return alias_map[norm] - # Try partial match — check if any alias key is contained in the extracted name - for alias_key, canonical in alias_map.items(): - if alias_key in norm or norm in alias_key: - return canonical - return extracted - - -def suppliers_match(flash_supplier: str, xero_supplier: str) -> bool: - """Check if a Flash supplier and resolved Xero supplier match.""" - return normalise_supplier(flash_supplier) == normalise_supplier(xero_supplier) - - -# ============ Matching Engine ============ - -def run_matching( - flash_items: list[dict], - xero_items: list[dict], - alias_map: dict[str, str], -) -> tuple[list, list, list[dict], list[dict]]: - """ - Three-pass matching: - 1. Exact: same normalised ref + same date + amount within £0.01 - 2. Near-match: one field differs (flagged as discrepancy) - 3. Leftovers: unmatched on each side - - Returns (matched, discrepancies, flash_unmatched, xero_unmatched) - """ - matched = [] - discrepancies = [] - - flash_used = set() - xero_used = set() - - # Resolve Xero suppliers up front - for xi, xero in enumerate(xero_items): - xero["_resolved_supplier"] = resolve_supplier_with_map(xero["description"], alias_map) - xero["_norm_ref"] = normalise_ref(xero["ref"]) - xero["_idx"] = xi - - for fi, flash in enumerate(flash_items): - flash["_norm_ref"] = normalise_ref(flash["ref"]) - flash["_idx"] = fi - - # ---- Pass 1: Exact match ---- - for fi, flash in enumerate(flash_items): - if fi in flash_used: - continue - if not flash["_norm_ref"]: - continue # Can't exact-match without a ref - for xi, xero in enumerate(xero_items): - if xi in xero_used: - continue - if flash["_norm_ref"] == xero["_norm_ref"] and \ - flash["date"] == xero["date"] and \ - amounts_match(flash["net_stock"], xero["net"]): - matched.append({ - "date": flash["date"].isoformat(), - "supplier": flash["supplier_name"], - "ref": flash["ref"] or xero["ref"], - "amount": fmt_money(flash["net_stock"]), - "flash_id": flash["id"], - }) - flash_used.add(fi) - xero_used.add(xi) - break - - # ---- Pass 2: Near-match (discrepancies) ---- - for fi, flash in enumerate(flash_items): - if fi in flash_used: - continue - best_match = None - best_score = 0 # higher = more confident - - for xi, xero in enumerate(xero_items): - if xi in xero_used: - continue - - differs = [] - score = 0 - - f_ref = flash["_norm_ref"] - x_ref = xero["_norm_ref"] - f_supplier = flash["supplier_name"] - x_supplier = xero["_resolved_supplier"] - same_supplier = suppliers_match(f_supplier, x_supplier) - - # 2a: Same ref, same date, amount differs - if f_ref and x_ref and f_ref == x_ref and flash["date"] == xero["date"]: - if not amounts_match(flash["net_stock"], xero["net"]): - differs.append("amount") - score = 10 - - # 2b: Same ref, amount matches, date differs ≤ 3 days - elif f_ref and x_ref and f_ref == x_ref and \ - amounts_match(flash["net_stock"], xero["net"]): - date_diff = abs((flash["date"] - xero["date"]).days) - if date_diff <= 3: - differs.append("date") - score = 9 - - # 2c: Same supplier, amount matches (within £0.01), date within 3 days, refs differ - # This catches cases where refs are completely different - # (e.g. Cotswold Coffee 126621 vs 145323, R&D Walker Nº4 vs 106891) - elif same_supplier and amounts_match(flash["net_stock"], xero["net"], Decimal("0.01")): - date_diff = abs((flash["date"] - xero["date"]).days) - if date_diff == 0: - differs.append("ref") - score = 8 - elif date_diff <= 3: - differs.extend(["ref", "date"]) - score = 7 - - # 2d: Same supplier, same ref, both amount and date differ slightly - elif f_ref and x_ref and f_ref == x_ref and same_supplier: - date_diff = abs((flash["date"] - xero["date"]).days) - amt_diff = abs(flash["net_stock"] - xero["net"]) - if date_diff <= 3 and amt_diff <= Decimal("5.00"): - differs.extend(["amount", "date"]) - score = 5 - - # 2e: Credit note matching — same supplier, same amount, - # but refs differ completely (Xero uses CN prefix, Flash uses supplier ref) - elif same_supplier and amounts_match(flash["net_stock"], xero["net"]) and \ - (flash["document_type"] == "credit_note" or xero.get("is_credit_note", False)): - date_diff = abs((flash["date"] - xero["date"]).days) - if date_diff <= 5: - differs.append("ref") - if date_diff > 0: - differs.append("date") - score = 8 # High confidence — credit notes with matching supplier+amount - - # 2f: Same supplier, date within 3 days, amounts close but not exact - elif same_supplier: - date_diff = abs((flash["date"] - xero["date"]).days) - amt_diff = abs(flash["net_stock"] - xero["net"]) - if date_diff <= 3 and amt_diff <= Decimal("5.00") and amt_diff > Decimal("0.01"): - differs_list = ["amount"] - if date_diff > 0: - differs_list.append("date") - if f_ref != x_ref: - differs_list.append("ref") - differs.extend(differs_list) - score = 4 - - if score > best_score: - best_score = score - best_match = (xi, xero, differs) - - if best_match: - xi, xero, differs = best_match - f_net = flash["net_stock"] - x_net = xero["net"] - diff_val = f_net - x_net - amt_diff_abs = abs(float(diff_val)) - - # Safety check: reject any near-match where amount differs by more than £5 - # AND refs are different — these are almost certainly different invoices - f_ref_norm = normalise_ref(flash["ref"]) - x_ref_norm = normalise_ref(xero["ref"]) - refs_differ = f_ref_norm != x_ref_norm - - if refs_differ and amt_diff_abs > 5.0: - logger.warning( - f"REJECTED false discrepancy: Flash {flash['supplier_name']} " - f"{flash['ref']}={f_net} vs Xero {xero['ref']}={x_net} " - f"diff={amt_diff_abs:.2f} score={best_score}" - ) - continue # Skip — leave both as unmatched - - # Add "amount" to differs if amounts don't actually match - if amt_diff_abs > 0.01 and "amount" not in differs: - differs.append("amount") - - logger.info( - f"Discrepancy: Flash {flash['supplier_name']} {flash['ref']}={f_net} " - f"vs Xero {xero['ref']}={x_net} differs={differs} score={best_score}" - ) - - discrepancies.append({ - "date": flash["date"].isoformat(), - "supplier": flash["supplier_name"], - "ref": flash["ref"] or xero["ref"], - "flash_amount": fmt_money(f_net), - "xero_amount": fmt_money(x_net), - "difference": fmt_money(diff_val), - "differs": differs, - "flash_id": flash["id"], - "flash_date": flash["date"].isoformat(), - "xero_date": xero["date"].isoformat(), - "flash_ref": flash["ref"], - "xero_ref": xero["ref"], - "flash_supplier": flash["supplier_name"], - "xero_description": xero["description"], - }) - flash_used.add(fi) - xero_used.add(xi) - - # ---- Leftovers ---- - flash_unmatched = [f for fi, f in enumerate(flash_items) if fi not in flash_used] - xero_unmatched = [x for xi, x in enumerate(xero_items) if xi not in xero_used] - - return matched, discrepancies, flash_unmatched, xero_unmatched - - -# ============ Amount Discrepancy Insights ============ - -def check_non_stock_explains_diff(flash: dict, diff_val: Decimal) -> str | None: - """ - Check if the amount discrepancy is explained by non-stock items. - If Xero includes the full invoice (stock + non-stock) but Flash only shows stock, - the difference should equal the non-stock total. - """ - net_non_stock = flash.get("net_non_stock", Decimal("0")) - if net_non_stock == 0: - return None - - # diff_val = flash_stock - xero_net (negative when Xero is higher) - # If Xero has full invoice, diff = -net_non_stock - if amounts_match(abs(diff_val), abs(net_non_stock), Decimal("0.02")): - non_stock_items = [ - li for li in flash.get("line_items", []) if li.get("is_non_stock") - ] - item_names = ", ".join( - li["description"][:40] for li in non_stock_items if li.get("description") - ) - return ( - f"Non-stock items account for the difference " - f"({fmt_money(abs(net_non_stock))}). " - f"Xero likely includes full invoice total. " - f"Non-stock: {item_names}" if item_names else - f"Non-stock items account for the difference " - f"({fmt_money(abs(net_non_stock))}). " - f"Xero likely includes full invoice total." - ) - - # Check if non-stock is a partial explanation (diff is larger but non-stock is a chunk) - if abs(net_non_stock) > Decimal("1.00") and abs(diff_val) > abs(net_non_stock): - remainder = abs(diff_val) - abs(net_non_stock) - return ( - f"Non-stock items total {fmt_money(abs(net_non_stock))} " - f"but difference is {fmt_money(abs(diff_val))} — " - f"non-stock explains part, {fmt_money(remainder)} remains unexplained." - ) - - return None - - -def check_line_item_combinations(flash: dict, diff_val: Decimal) -> str | None: - """ - Check if any single line item or small combination matches the difference. - This catches cases where a specific item was excluded/included differently. - """ - line_items = flash.get("line_items", []) - if not line_items: - return None - - target = abs(diff_val) - - # Check single items - for li in line_items: - amt = abs(Decimal(li["amount"])) - if amounts_match(amt, target, Decimal("0.02")) and amt > Decimal("0.50"): - desc = li["description"][:50] if li["description"] else "unnamed item" - ns = " (non-stock)" if li.get("is_non_stock") else "" - return f'Single line item matches difference: "{desc}"{ns} = {fmt_money(amt)}' - - return None - - -async def generate_llm_insight( - db: AsyncSession, - kitchen_id: int, - flash: dict, - xero_net: Decimal, - diff_val: Decimal, -) -> str | None: - """Use LLM to analyse line items and suggest cause of amount variance.""" - from services.llm_service import call_llm - - line_items = flash.get("line_items", []) - if not line_items: - return None - - # Build concise line item list - li_summary = [] - for li in line_items: - li_summary.append({ - "description": li["description"][:60] if li["description"] else "—", - "amount": li["amount"], - "non_stock": li["is_non_stock"], - }) - - system_msg = ( - "You are a kitchen accounts assistant. Flash is a food stock/GP system that " - "tracks invoices — it separates stock (food) items from non-stock items " - "(chemicals, packaging, equipment, etc). Xero is the accounting system that " - "records the full invoice total posted to the food purchases account. " - "An amount discrepancy means Flash stock total differs from the Xero net. " - "Common causes: non-stock items not split out in Xero, line items missing, " - "rounding, or Xero posting error." - ) - - user_msg = ( - f"Invoice: {flash['supplier_name']} ref {flash['ref']} dated {flash['date']}\n" - f"Flash stock total: {fmt_money(flash['net_stock'])}\n" - f"Xero net: {fmt_money(xero_net)}\n" - f"Difference: {fmt_money(diff_val)} (Flash - Xero)\n\n" - f"Flash line items:\n{json.dumps(li_summary, indent=2)}\n\n" - f"Can you identify which line items or combination likely accounts for " - f"the {fmt_money(abs(diff_val))} difference? " - f"Reply in ONE short sentence (max 120 chars). " - f"If unclear, say 'Unable to determine cause'." - ) - - result = await call_llm( - db=db, - kitchen_id=kitchen_id, - feature="reconciliation_insight", - messages=[{"role": "user", "content": user_msg}], - system_message=system_msg, - ) - - if result["status"] not in ("success", "cached"): - return None - - text = result.get("result", "") - if isinstance(text, dict): - text = str(text) - text = text.strip().strip('"').strip("'") - if text and len(text) < 200: - return text - return text[:200] + "..." if text else None - - -async def generate_amount_insights( - db: AsyncSession, - kitchen_id: int, - discrepancies: list[dict], - flash_lookup: dict[int, dict], -) -> list[dict]: - """ - For each amount discrepancy, try to explain the variance: - 1. Code check: does non-stock total match the difference? - 2. Code check: does a single line item match the difference? - 3. LLM fallback: send line items for analysis - """ - for disc in discrepancies: - if "amount" not in disc.get("differs", []): - continue - - flash_id = disc.get("flash_id") - if not flash_id or flash_id not in flash_lookup: - continue - - flash = flash_lookup[flash_id] - - # Parse the difference back to Decimal - diff_str = disc["difference"].replace("£", "").replace(",", "") - try: - diff_val = Decimal(diff_str) - except InvalidOperation: - continue - - # 1. Non-stock check - insight = check_non_stock_explains_diff(flash, diff_val) - if insight: - disc["amount_insight"] = insight - continue - - # 2. Single line item check - insight = check_line_item_combinations(flash, diff_val) - if insight: - disc["amount_insight"] = insight - continue - - # 3. LLM fallback - xero_str = disc["xero_amount"].replace("£", "").replace(",", "") - try: - xero_net = Decimal(xero_str) - except InvalidOperation: - continue - - insight = await generate_llm_insight(db, kitchen_id, flash, xero_net, diff_val) - if insight: - disc["amount_insight"] = f"🤖 {insight}" - - return discrepancies - - -# ============ LLM Fallback Matching ============ - -async def llm_match_remaining( - db: AsyncSession, - kitchen_id: int, - flash_unmatched: list[dict], - xero_unmatched: list[dict], -) -> tuple[list, list, list[dict], list[dict]]: - """ - Use LLM to attempt matching remaining unmatched items. - Returns (new_discrepancies, new_exact, remaining_flash, remaining_xero) - """ - from services.llm_service import call_llm - - if not flash_unmatched or not xero_unmatched: - return [], [], flash_unmatched, xero_unmatched - - # Build concise representations - flash_summary = [] - for i, f in enumerate(flash_unmatched): - flash_summary.append({ - "idx": i, - "date": f["date"].isoformat(), - "supplier": f["supplier_name"], - "ref": f["ref"], - "amount": str(f["net_stock"]), - }) - - xero_summary = [] - for i, x in enumerate(xero_unmatched): - xero_summary.append({ - "idx": i, - "date": x["date"].isoformat(), - "description": x["description"], - "ref": x["ref"], - "amount": str(x["net"]), - }) - - system_msg = """You are a bookkeeping reconciliation assistant. You are given two lists of unmatched invoice entries — one from Flash (the kitchen invoice system) and one from Xero (the accounting system). - -Your job is to identify probable matches between the two lists. These are entries that likely represent the same real-world invoice but have data discrepancies (different reference numbers, slightly different amounts, date offsets, supplier name variants, etc). - -For each probable match, explain which fields differ and why you think they are the same invoice. - -IMPORTANT: Only suggest matches you are reasonably confident about. It is better to leave items unmatched than to create false matches. Consider supplier names, dates, amounts, and reference numbers holistically.""" - - user_msg = f"""Here are the unmatched Flash invoices: -{json.dumps(flash_summary, indent=2)} - -Here are the unmatched Xero entries: -{json.dumps(xero_summary, indent=2)} - -Return a JSON array of matches. Each match should be: -{{ - "flash_idx": , - "xero_idx": , - "confidence": "high" or "medium", - "differs": ["field1", "field2"], - "reasoning": "brief explanation" -}} - -Only include matches with high or medium confidence. Return an empty array [] if no good matches found.""" - - result = await call_llm( - db=db, - kitchen_id=kitchen_id, - feature="reconciliation_matching", - messages=[{"role": "user", "content": user_msg}], - system_message=system_msg, - ) - - if result["status"] not in ("success", "cached"): - logger.info(f"LLM reconciliation matching unavailable: {result['status']}") - return [], [], flash_unmatched, xero_unmatched - - # Parse LLM response - new_discrepancies = [] - llm_text = result.get("result", "") - if isinstance(llm_text, dict): - llm_text = json.dumps(llm_text) - if not llm_text: - return [], [], flash_unmatched, xero_unmatched - - try: - # Extract JSON from response (may be wrapped in markdown code block) - json_match = re.search(r"\[.*\]", str(llm_text), re.DOTALL) - if not json_match: - return [], [], flash_unmatched, xero_unmatched - matches = json.loads(json_match.group()) - except (json.JSONDecodeError, AttributeError): - logger.warning("Failed to parse LLM reconciliation response") - return [], [], flash_unmatched, xero_unmatched - - flash_matched = set() - xero_matched = set() - - for m in matches: - fi = m.get("flash_idx") - xi = m.get("xero_idx") - confidence = m.get("confidence", "medium") - - if fi is None or xi is None: - continue - if fi >= len(flash_unmatched) or xi >= len(xero_unmatched): - continue - if fi in flash_matched or xi in xero_matched: - continue - - flash = flash_unmatched[fi] - xero = xero_unmatched[xi] - - # Safety check: reject LLM matches where refs differ AND amount diff > £5 - f_ref_norm = normalise_ref(flash["ref"]) - x_ref_norm = normalise_ref(xero["ref"]) - amt_diff_abs = abs(float(flash["net_stock"] - xero["net"])) - if f_ref_norm != x_ref_norm and amt_diff_abs > 5.0: - logger.warning( - f"REJECTED LLM false match: Flash {flash['supplier_name']} " - f"{flash['ref']}={flash['net_stock']} vs Xero {xero['ref']}={xero['net']} " - f"diff={amt_diff_abs:.2f}" - ) - continue - - differs = m.get("differs", []) - if not differs: - differs = ["unknown"] - - diff_val = flash["net_stock"] - xero["net"] - new_discrepancies.append({ - "date": flash["date"].isoformat(), - "supplier": flash["supplier_name"], - "ref": flash["ref"] or xero["ref"], - "flash_amount": fmt_money(flash["net_stock"]), - "xero_amount": fmt_money(xero["net"]), - "difference": fmt_money(diff_val), - "differs": differs, - "flash_id": flash["id"], - "flash_date": flash["date"].isoformat(), - "xero_date": xero["date"].isoformat(), - "flash_ref": flash["ref"], - "xero_ref": xero["ref"], - "match_source": "ai", - }) - flash_matched.add(fi) - xero_matched.add(xi) - - remaining_flash = [f for i, f in enumerate(flash_unmatched) if i not in flash_matched] - remaining_xero = [x for i, x in enumerate(xero_unmatched) if i not in xero_matched] - - return new_discrepancies, [], remaining_flash, remaining_xero - - -# ============ Main Endpoint ============ - -@router.post("/purchases/reconcile", response_model=ReconcileResponse) -async def reconcile_purchases( - file: UploadFile = File(...), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Upload a Xero Account Transactions XLSX and reconcile against Flash invoices. - """ - # Validate file type - if not file.filename or not file.filename.lower().endswith(".xlsx"): - raise HTTPException(status_code=400, detail="Please upload an XLSX file") - - # Read file into memory (no persistence) - file_bytes = await file.read() - if len(file_bytes) > 10 * 1024 * 1024: # 10MB limit - raise HTTPException(status_code=400, detail="File too large (max 10MB)") - - # Parse XLSX - try: - period_start, period_end, xero_rows = parse_xero_xlsx(file_bytes) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"XLSX parsing failed: {e}") - raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {str(e)}") - - # Group Xero rows by reference - xero_items = group_xero_by_ref(xero_rows) - - # Get Flash invoices for the period - flash_items, non_stock_count, non_stock_total = await get_flash_invoices( - db, current_user.kitchen_id, period_start, period_end - ) - - # Build supplier alias map from DB - alias_map = await build_supplier_alias_map(db, current_user.kitchen_id) - - # Run rule-based matching - matched, discrepancies, flash_unmatched, xero_unmatched = run_matching( - flash_items, xero_items, alias_map - ) - - # LLM fallback for remaining unmatched - llm_attempted = False - if flash_unmatched and xero_unmatched: - llm_disc, llm_exact, flash_unmatched, xero_unmatched = await llm_match_remaining( - db, current_user.kitchen_id, flash_unmatched, xero_unmatched - ) - if llm_disc or llm_exact: - llm_attempted = True - discrepancies.extend(llm_disc) - matched.extend(llm_exact) - - # Generate insights for amount discrepancies - flash_lookup = {f["id"]: f for f in flash_items} - discrepancies = await generate_amount_insights( - db, current_user.kitchen_id, discrepancies, flash_lookup - ) - - # Calculate totals - flash_total = sum(f["net_stock"] for f in flash_items) - xero_total = sum(x["net"] for x in xero_items) - difference = flash_total - xero_total - - # Build response - matched_response = [ReconcileMatchedItem(**m) for m in matched] - - discrepancy_response = [ReconcileDiscrepancyItem(**d) for d in discrepancies] - - flash_only_response = [ - ReconcileUnmatchedFlash( - date=f["date"].isoformat(), - supplier=f["supplier_name"], - ref=f["ref"], - net_stock=fmt_money(f["net_stock"]), - flash_id=f["id"], - ) - for f in flash_unmatched - ] - - xero_only_response = [ - ReconcileUnmatchedXero( - date=x["date"].isoformat(), - description=x["description"], - ref=x["ref"], - net=fmt_money(x["net"]), - is_expected_external=is_expected_external(x["description"]), - ) - for x in xero_unmatched - ] - - return ReconcileResponse( - period_start=period_start.isoformat(), - period_end=period_end.isoformat(), - flash_total=fmt_money(flash_total), - xero_total=fmt_money(xero_total), - difference=fmt_money(difference), - matched_count=len(matched_response), - discrepancy_count=len(discrepancy_response), - flash_only_count=len(flash_only_response), - xero_only_count=len(xero_only_response), - non_stock_excluded_count=non_stock_count, - non_stock_excluded_total=fmt_money(non_stock_total), - matched=matched_response, - discrepancies=discrepancy_response, - flash_only=flash_only_response, - xero_only=xero_only_response, - llm_matches_attempted=llm_attempted, - ) diff --git a/backend/api/reports.py b/backend/api/reports.py deleted file mode 100644 index 2aa0a00..0000000 --- a/backend/api/reports.py +++ /dev/null @@ -1,3421 +0,0 @@ -from datetime import date, timedelta -from decimal import Decimal -from typing import Optional -import logging - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, case -from sqlalchemy.orm import selectinload -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -from database import get_db -from models.user import User -from models.invoice import Invoice, InvoiceStatus -from models.gp import RevenueEntry, GPPeriod -from models.newbook import NewbookDailyRevenue, NewbookGLAccount, NewbookDailyOccupancy -from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus -from auth import get_current_user, require_cap - -router = APIRouter() - - -class RevenueEntryCreate(BaseModel): - date: date - amount: Decimal - category: str = "total" - notes: Optional[str] = None - - -class RevenueEntryResponse(BaseModel): - id: int - date: date - amount: Decimal - category: str - notes: Optional[str] - - class Config: - from_attributes = True - - -class GPReportRequest(BaseModel): - start_date: date - end_date: date - - -class GPReportResponse(BaseModel): - start_date: date - end_date: date - total_revenue: Decimal - total_costs: Decimal - gp_amount: Decimal - gp_percentage: Decimal - category_breakdown: dict - # Revenue breakdown - newbook_revenue: Optional[Decimal] = None - manual_revenue: Optional[Decimal] = None - # Allowances (credits that improve GP if applied) - wastage_total: Optional[Decimal] = None # Wastage logged in logbook - disputes_total: Optional[Decimal] = None # Open disputes on invoices in this period - allowances_total: Optional[Decimal] = None # wastage + disputes combined - gp_with_allowances: Optional[Decimal] = None # GP% if allowances applied - - -class DashboardResponse(BaseModel): - current_period: GPReportResponse | None - previous_period: GPReportResponse | None - forecast_period: GPReportResponse | None # Placeholder for this week's forecast - rolling_30_days: GPReportResponse | None # Last 30 days rolling (from yesterday) - recent_invoices: int - pending_review: int - - -class PurchaseInvoice(BaseModel): - id: int - invoice_number: str | None - total: Decimal | None - supplier_match_type: str | None # "exact", "fuzzy", or None (unmatched) - - class Config: - from_attributes = True - - -class SupplierRow(BaseModel): - supplier_id: int | None # None for unmatched invoices - supplier_name: str # Supplier name or vendor_name for unmatched - is_unmatched: bool - invoices_by_date: dict[str, list[PurchaseInvoice]] # date string -> invoices - total: Decimal - percentage: Decimal - - -class WeeklyPurchasesResponse(BaseModel): - week_start: date - week_end: date - dates: list[date] # 7 days - suppliers: list[SupplierRow] - daily_totals: dict[str, Decimal] # date string -> total - week_total: Decimal - - -# Monthly Purchases Calendar models -class MonthlyPurchaseInvoice(BaseModel): - """Invoice with full detail for monthly view""" - id: int - invoice_number: str | None - invoice_date: date | None - total: Decimal | None # Gross total (inc. VAT) - net_total: Decimal | None # Net total (exc. VAT) - net_stock: Decimal | None # Net stock items only - gross_stock: Decimal | None # Gross stock items only (net_stock + stock VAT) - supplier_match_type: str | None - - class Config: - from_attributes = True - - -class MonthlySupplierRow(BaseModel): - """Supplier row for monthly purchases - consistent order across weeks""" - supplier_id: int | None - supplier_name: str - is_unmatched: bool - invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] # date string -> invoices - total_net_stock: Decimal # Sum of net_stock for all invoices - percentage: Decimal - - -class WeekData(BaseModel): - """Data for one week in the monthly view""" - week_start: date - week_end: date - dates: list[date] # 7 days (Mon-Sun) - suppliers: list[MonthlySupplierRow] # Same order as month-level suppliers - daily_totals: dict[str, Decimal] # date string -> net_stock total - week_total: Decimal # Net stock total for week - daily_invoice_totals: dict[str, Decimal] | None = None # date string -> full invoice net_total - week_invoice_total: Decimal | None = None # Full invoice net_total for week - - -class MonthlyPurchasesResponse(BaseModel): - """Response for monthly purchases calendar view""" - year: int - month: int - month_name: str - weeks: list[WeekData] # All weeks in the month - all_suppliers: list[str] # Ordered list of all supplier names for consistent display - daily_totals: dict[str, Decimal] # All days in month -> net_stock total - month_total: Decimal # Net stock total for entire month - - -class DateRangePurchasesResponse(BaseModel): - """Response for date range purchases view""" - from_date: date - to_date: date - period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026" - weeks: list[WeekData] # All weeks in the range - all_suppliers: list[str] # Ordered list of all supplier names for consistent display - daily_totals: dict[str, Decimal] # All days in range -> net_stock total - period_total: Decimal # Net stock total for entire period - daily_invoice_totals: dict[str, Decimal] | None = None # All days -> full invoice net_total - period_invoice_total: Decimal | None = None # Full invoice net_total for entire period - - -@router.post("/revenue", response_model=RevenueEntryResponse) -async def add_revenue( - request: RevenueEntryCreate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Add a revenue entry for GP calculation""" - entry = RevenueEntry( - kitchen_id=current_user.kitchen_id, - date=request.date, - amount=request.amount, - category=request.category, - notes=request.notes - ) - db.add(entry) - await db.commit() - await db.refresh(entry) - - return RevenueEntryResponse( - id=entry.id, - date=entry.date, - amount=entry.amount, - category=entry.category, - notes=entry.notes - ) - - -@router.get("/revenue", response_model=list[RevenueEntryResponse]) -async def list_revenue( - start_date: Optional[date] = None, - end_date: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List revenue entries for a date range""" - query = select(RevenueEntry).where( - RevenueEntry.kitchen_id == current_user.kitchen_id - ) - - if start_date: - query = query.where(RevenueEntry.date >= start_date) - if end_date: - query = query.where(RevenueEntry.date <= end_date) - - query = query.order_by(RevenueEntry.date.desc()) - result = await db.execute(query) - entries = result.scalars().all() - - return [ - RevenueEntryResponse( - id=e.id, - date=e.date, - amount=e.amount, - category=e.category, - notes=e.notes - ) - for e in entries - ] - - -@router.post("/gp", response_model=GPReportResponse) -async def calculate_gp( - request: GPReportRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Calculate GP for a specific date range""" - from models.line_item import LineItem - from sqlalchemy import or_, and_ - - # Get manual revenue entries for period - manual_revenue_result = await db.execute( - select(func.sum(RevenueEntry.amount)) - .where( - RevenueEntry.kitchen_id == current_user.kitchen_id, - RevenueEntry.date >= request.start_date, - RevenueEntry.date <= request.end_date - ) - ) - manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") - - # Get Newbook revenue for tracked GL accounts - newbook_revenue_result = await db.execute( - select(func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= request.start_date, - NewbookDailyRevenue.date <= request.end_date, - NewbookGLAccount.is_tracked == True - ) - ) - newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") - - # Total revenue combines manual entries and Newbook data - total_revenue = manual_revenue + newbook_revenue - - # Get total costs from confirmed invoices - stock items only - # Credit notes are treated as negative purchases - # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total - # This matches the flash report's calc_stock_values logic - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.document_type.label('doc_type'), - Invoice.category.label('category'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= request.start_date, - Invoice.invoice_date <= request.end_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.document_type, Invoice.category) - .subquery() - ) - - costs_result = await db.execute( - select(func.sum( - case( - # Credit note with positive total - negate it - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - # Otherwise use as-is (includes credit notes with already-negative totals) - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - total_costs = costs_result.scalar() or Decimal("0.00") - - # Add cost distribution adjustments (net zero overall, but shifts cost between dates) - cd_adjustment_result = await db.execute( - select(func.sum(CostDistributionEntry.amount)) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= request.start_date, - CostDistributionEntry.entry_date <= request.end_date, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - ) - total_costs += cd_adjustment_result.scalar() or Decimal("0.00") - - # Calculate GP - gp_amount = total_revenue - total_costs - gp_percentage = (gp_amount / total_revenue * 100) if total_revenue > 0 else Decimal("0.00") - - # Category breakdown for costs (using same subquery approach) - category_result = await db.execute( - select( - invoice_stock_subq.c.category, - func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - ) - ) - .select_from(invoice_stock_subq) - .group_by(invoice_stock_subq.c.category) - ) - category_breakdown = { - cat or "uncategorized": float(amount) - for cat, amount in category_result.all() - } - - return GPReportResponse( - start_date=request.start_date, - end_date=request.end_date, - total_revenue=total_revenue, - total_costs=total_costs, - gp_amount=gp_amount, - gp_percentage=round(gp_percentage, 2), - category_breakdown=category_breakdown, - newbook_revenue=newbook_revenue if newbook_revenue > 0 else None, - manual_revenue=manual_revenue if manual_revenue > 0 else None - ) - - -@router.get("/dashboard", response_model=DashboardResponse) -async def get_dashboard( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get dashboard summary with current and previous period GP""" - today = date.today() - - # Current week (Monday to today) - current_start = today - timedelta(days=today.weekday()) - current_end = today - - # Previous week - prev_start = current_start - timedelta(days=7) - prev_end = current_start - timedelta(days=1) - - async def calc_period_gp(start: date, end: date) -> GPReportResponse | None: - from models.line_item import LineItem - from models.logbook import LogbookEntry, EntryType - from models.dispute import InvoiceDispute, DisputeStatus - from sqlalchemy import or_ - - # Manual revenue entries - manual_rev_result = await db.execute( - select(func.sum(RevenueEntry.amount)) - .where( - RevenueEntry.kitchen_id == current_user.kitchen_id, - RevenueEntry.date >= start, - RevenueEntry.date <= end - ) - ) - manual_revenue = manual_rev_result.scalar() or Decimal("0.00") - - # Newbook revenue for tracked GL accounts - newbook_rev_result = await db.execute( - select(func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= start, - NewbookDailyRevenue.date <= end, - NewbookGLAccount.is_tracked == True - ) - ) - newbook_revenue = newbook_rev_result.scalar() or Decimal("0.00") - - # Total revenue - revenue = manual_revenue + newbook_revenue - - # Costs - stock items only (exclude non-stock) - # Credit notes (document_type='credit_note') are treated as negative purchases - # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total - # This matches the flash report's calc_stock_values logic - from sqlalchemy import and_ - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= start, - Invoice.invoice_date <= end, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.document_type) - .subquery() - ) - cost_result = await db.execute( - select(func.sum( - case( - # Credit note with positive total - negate it - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - # Otherwise use as-is (includes credit notes with already-negative totals) - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - costs = cost_result.scalar() or Decimal("0.00") - - # Add cost distribution adjustments - cd_adj_result = await db.execute( - select(func.sum(CostDistributionEntry.amount)) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= start, - CostDistributionEntry.entry_date <= end, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - ) - costs += cd_adj_result.scalar() or Decimal("0.00") - - # Wastage total from logbook - wastage_result = await db.execute( - select(func.sum(LogbookEntry.total_cost)) - .where( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.entry_date >= start, - LogbookEntry.entry_date <= end, - LogbookEntry.entry_type == EntryType.WASTAGE, - LogbookEntry.is_deleted == False - ) - ) - wastage_total = wastage_result.scalar() or Decimal("0.00") - - # Open disputes total - based on invoice date, not dispute creation date - # These are potential credits that would reduce costs if resolved - open_statuses = [ - DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, - DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, - DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED - ] - disputes_result = await db.execute( - select(func.sum(InvoiceDispute.difference_amount)) - .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) - .where( - InvoiceDispute.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= start, - Invoice.invoice_date <= end, - InvoiceDispute.status.in_(open_statuses) - ) - ) - disputes_total = disputes_result.scalar() or Decimal("0.00") - - if revenue == 0 and costs == 0: - return None - - gp_amount = revenue - costs - gp_pct = (gp_amount / revenue * 100) if revenue > 0 else Decimal("0.00") - - # Calculate GP with allowances (wastage + disputes as credits) - # Allowances = money that could be recovered/saved - # - Wastage: if not wasted, wouldn't have purchased - # - Disputes: credits expected from suppliers - allowances_total = wastage_total + disputes_total - gp_with_allowances = revenue - costs + allowances_total - gp_with_allowances_pct = (gp_with_allowances / revenue * 100) if revenue > 0 else Decimal("0.00") - - # Only show allowances section if there are any - has_allowances = allowances_total > 0 - - return GPReportResponse( - start_date=start, - end_date=end, - total_revenue=revenue, - total_costs=costs, - gp_amount=gp_amount, - gp_percentage=round(gp_pct, 2), - category_breakdown={}, - newbook_revenue=newbook_revenue if newbook_revenue > 0 else None, - manual_revenue=manual_revenue if manual_revenue > 0 else None, - wastage_total=wastage_total if wastage_total > 0 else None, - disputes_total=disputes_total if disputes_total > 0 else None, - allowances_total=allowances_total if has_allowances else None, - gp_with_allowances=round(gp_with_allowances_pct, 2) if has_allowances else None - ) - - # Recent invoices count (last 7 days) - recent_result = await db.execute( - select(func.count(Invoice.id)) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.created_at >= today - timedelta(days=7) - ) - ) - recent_invoices = recent_result.scalar() or 0 - - # Pending confirmation count (all non-confirmed: pending, processed, reviewed) - from sqlalchemy import or_ - pending_result = await db.execute( - select(func.count(Invoice.id)) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - or_( - Invoice.status == InvoiceStatus.PENDING, - Invoice.status == InvoiceStatus.PROCESSED, - Invoice.status == InvoiceStatus.REVIEWED - ) - ) - ) - pending_review = pending_result.scalar() or 0 - - # Rolling 30 days (from yesterday back 29 days) - yesterday = today - timedelta(days=1) - rolling_30_start = yesterday - timedelta(days=29) - rolling_30_end = yesterday - - return DashboardResponse( - current_period=await calc_period_gp(current_start, current_end), - previous_period=await calc_period_gp(prev_start, prev_end), - forecast_period=None, # Placeholder - forecast not implemented yet - rolling_30_days=await calc_period_gp(rolling_30_start, rolling_30_end), - recent_invoices=recent_invoices, - pending_review=pending_review - ) - - -@router.get("/purchases/weekly", response_model=WeeklyPurchasesResponse) -async def get_weekly_purchases( - week_offset: int = 0, # 0 = current week, -1 = last week, etc. - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get weekly purchases organized by supplier and date for table view""" - from models.supplier import Supplier - from collections import defaultdict - - today = date.today() - # Calculate week start (Monday) with offset - week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=week_offset) - week_end = week_start + timedelta(days=6) - dates = [week_start + timedelta(days=i) for i in range(7)] - - # Get all invoices for the week (all statuses, matched or not) - # Fetch all recent invoices and filter in Python for reliable date handling - result = await db.execute( - select(Invoice) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - ) - .order_by(Invoice.invoice_date.desc().nullslast()) - ) - all_invoices = result.scalars().all() - - # Filter to invoices in the week range, using invoice_date or created_at as fallback - invoices = [] - for inv in all_invoices: - inv_date = inv.invoice_date or inv.created_at.date() - if week_start <= inv_date <= week_end: - invoices.append(inv) - - # Get all suppliers for name lookup - supplier_result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) - ) - suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} - - # Organize invoices by supplier - supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoices - - for inv in invoices: - if inv.supplier_id: - key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) - else: - # Unmatched - use vendor_name or "Unknown Supplier" - vendor = inv.vendor_name or "Unknown Supplier" - key = (None, vendor, True) - supplier_invoices[key].append(inv) - - # Helper to get effective total (negative for credit notes) - def get_effective_total(inv: Invoice) -> Decimal: - total = inv.total or Decimal("0") - if inv.document_type == 'credit_note': - return -total - return total - - # Calculate week total - week_total = sum(get_effective_total(inv) for inv in invoices) - - # Build supplier rows - supplier_rows = [] - for (supplier_id, supplier_name, is_unmatched), invs in sorted( - supplier_invoices.items(), key=lambda x: (x[0][2], x[0][1].lower()) # Matched first, then alphabetical - ): - invoices_by_date: dict[str, list[PurchaseInvoice]] = defaultdict(list) - row_total = Decimal("0") - - for inv in invs: - # Use invoice_date if available, otherwise use created_at date - inv_date = inv.invoice_date or inv.created_at.date() - date_str = inv_date.isoformat() - invoices_by_date[date_str].append(PurchaseInvoice( - id=inv.id, - invoice_number=inv.invoice_number, - total=get_effective_total(inv), # Negative for credit notes - supplier_match_type=inv.supplier_match_type - )) - row_total += get_effective_total(inv) - - percentage = (row_total / week_total * 100) if week_total > 0 else Decimal("0") - - supplier_rows.append(SupplierRow( - supplier_id=supplier_id, - supplier_name=supplier_name, - is_unmatched=is_unmatched, - invoices_by_date=dict(invoices_by_date), - total=row_total, - percentage=round(percentage, 1) - )) - - # Calculate daily totals - daily_totals = {} - for d in dates: - date_str = d.isoformat() - daily_totals[date_str] = sum( - get_effective_total(inv) - for inv in invoices - if (inv.invoice_date or inv.created_at.date()).isoformat() == date_str - ) - - return WeeklyPurchasesResponse( - week_start=week_start, - week_end=week_end, - dates=dates, - suppliers=supplier_rows, - daily_totals=daily_totals, - week_total=week_total - ) - - -@router.get("/purchases/monthly", response_model=MonthlyPurchasesResponse) -async def get_monthly_purchases( - year: int | None = None, - month: int | None = None, # 1-12 - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get monthly purchases organized by week, supplier, and date for calendar view""" - from models.supplier import Supplier - from models.line_item import LineItem - from collections import defaultdict - from calendar import monthrange, month_name as calendar_month_name - - # Default to current month - today = date.today() - year = year or today.year - month = month or today.month - - # Get first and last day of month - _, days_in_month = monthrange(year, month) - month_start = date(year, month, 1) - month_end = date(year, month, days_in_month) - - # Get all invoices for the month with their line items - result = await db.execute( - select(Invoice) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - ) - .options(selectinload(Invoice.line_items)) - .order_by(Invoice.invoice_date.desc().nullslast()) - ) - all_invoices = result.scalars().all() - - # Filter to invoices in the month range - invoices = [] - for inv in all_invoices: - inv_date = inv.invoice_date or inv.created_at.date() - if month_start <= inv_date <= month_end: - invoices.append(inv) - - # Get all suppliers for name lookup - supplier_result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) - ) - suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} - - # Helper to calculate stock values for an invoice - def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]: - """Returns (net_stock, gross_stock) for an invoice. - Credit notes (document_type='credit_note') return negative values. - """ - net_stock = Decimal("0") - if inv.line_items: - for item in inv.line_items: - if not (item.is_non_stock or False): - item_net = item.amount or Decimal("0") - net_stock += item_net - - # Calculate gross_stock by applying invoice's VAT ratio to net_stock - # (since line items often don't have individual tax_amount) - if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0: - vat_ratio = inv.total / inv.net_total - gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01")) - else: - gross_stock = net_stock - - # Credit notes are negative purchases - but only negate if values are positive - # Some suppliers already use negative values on credit note line items - if inv.document_type == 'credit_note': - if net_stock > 0: - net_stock = -net_stock - if gross_stock > 0: - gross_stock = -gross_stock - - return net_stock, gross_stock - - # Build invoice data with stock values - # Also negate total and net_total for credit notes so frontend sums work correctly - # Only negate if values are positive (some suppliers already use negative values) - invoice_data = {} - for inv in invoices: - net_stock, gross_stock = calc_stock_values(inv) - inv_date = inv.invoice_date or inv.created_at.date() - is_credit = inv.document_type == 'credit_note' - invoice_data[inv.id] = { - "inv": inv, - "date": inv_date, - "net_stock": net_stock, - "gross_stock": gross_stock, - "total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total, - "net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total, - } - - # Organize by supplier - supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoice ids - for inv_id, data in invoice_data.items(): - inv = data["inv"] - if inv.supplier_id: - key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) - else: - vendor = inv.vendor_name or "Unknown Supplier" - key = (None, vendor, True) - supplier_invoices[key].append(inv_id) - - # Get ordered list of all suppliers (matched first, then alphabetical) - all_supplier_keys = sorted( - supplier_invoices.keys(), - key=lambda x: (x[2], x[1].lower()) # is_unmatched, then name - ) - all_suppliers = [name for (_, name, _) in all_supplier_keys] - - # Calculate month total - month_total = sum(data["net_stock"] for data in invoice_data.values()) - - # Build weeks - find all weeks that overlap with the month - weeks_data = [] - - # Find first Monday on or before month start - first_monday = month_start - timedelta(days=month_start.weekday()) - - current_week_start = first_monday - while current_week_start <= month_end: - week_end = current_week_start + timedelta(days=6) - week_dates = [current_week_start + timedelta(days=i) for i in range(7)] - - # Build supplier rows for this week (maintain consistent order) - # First pass: collect data and calculate week_total - week_total = Decimal("0") - week_daily_totals: dict[str, Decimal] = defaultdict(Decimal) - supplier_data_list: list[tuple] = [] # (supplier_key, invoices_by_date, supplier_week_total) - - for supplier_key in all_supplier_keys: - supplier_id, supplier_name, is_unmatched = supplier_key - inv_ids = supplier_invoices.get(supplier_key, []) - - invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list) - supplier_week_total = Decimal("0") - - for inv_id in inv_ids: - data = invoice_data[inv_id] - inv = data["inv"] - inv_date = data["date"] - - # Only include if in this week - if current_week_start <= inv_date <= week_end: - date_str = inv_date.isoformat() - invoices_by_date[date_str].append(MonthlyPurchaseInvoice( - id=inv.id, - invoice_number=inv.invoice_number, - invoice_date=inv.invoice_date, - total=data["total"], - net_total=data["net_total"], - net_stock=data["net_stock"], - gross_stock=data["gross_stock"], - supplier_match_type=inv.supplier_match_type - )) - supplier_week_total += data["net_stock"] - week_daily_totals[date_str] += data["net_stock"] - - week_total += supplier_week_total - supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total)) - - # Second pass: calculate percentages using week_total - week_supplier_rows = [] - for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list: - supplier_id, supplier_name, is_unmatched = supplier_key - percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0") - - week_supplier_rows.append(MonthlySupplierRow( - supplier_id=supplier_id, - supplier_name=supplier_name, - is_unmatched=is_unmatched, - invoices_by_date=invoices_by_date, - total_net_stock=supplier_week_total, - percentage=round(percentage, 1) - )) - - weeks_data.append(WeekData( - week_start=current_week_start, - week_end=week_end, - dates=week_dates, - suppliers=week_supplier_rows, - daily_totals=dict(week_daily_totals), - week_total=week_total - )) - - current_week_start += timedelta(days=7) - - # Calculate daily totals for entire month - monthly_daily_totals: dict[str, Decimal] = defaultdict(Decimal) - for data in invoice_data.values(): - date_str = data["date"].isoformat() - monthly_daily_totals[date_str] += data["net_stock"] - - return MonthlyPurchasesResponse( - year=year, - month=month, - month_name=calendar_month_name[month], - weeks=weeks_data, - all_suppliers=all_suppliers, - daily_totals=dict(monthly_daily_totals), - month_total=month_total - ) - - -@router.get("/purchases/range", response_model=DateRangePurchasesResponse) -async def get_purchases_by_range( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get purchases organized by week for a custom date range""" - from models.supplier import Supplier - from models.line_item import LineItem - from collections import defaultdict - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build period label - if from_date.year == to_date.year: - if from_date.month == to_date.month: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" - - # Get all confirmed invoices for the range with their line items - result = await db.execute( - select(Invoice) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.status == InvoiceStatus.CONFIRMED, - ) - .options(selectinload(Invoice.line_items)) - .order_by(Invoice.invoice_date.desc().nullslast()) - ) - all_invoices = result.scalars().all() - - # Filter to invoices in the date range - invoices = [] - for inv in all_invoices: - inv_date = inv.invoice_date or inv.created_at.date() - if from_date <= inv_date <= to_date: - invoices.append(inv) - - # Get all suppliers for name lookup - supplier_result = await db.execute( - select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) - ) - suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} - - # Helper to calculate stock values for an invoice - def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]: - """Returns (net_stock, gross_stock) for an invoice. - Credit notes (document_type='credit_note') return negative values. - """ - net_stock = Decimal("0") - if inv.line_items: - for item in inv.line_items: - if not (item.is_non_stock or False): - item_net = item.amount or Decimal("0") - net_stock += item_net - - # Calculate gross_stock by applying invoice's VAT ratio to net_stock - if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0: - vat_ratio = inv.total / inv.net_total - gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01")) - else: - gross_stock = net_stock - - # Credit notes are negative purchases - but only negate if values are positive - # Some suppliers already use negative values on credit note line items - if inv.document_type == 'credit_note': - if net_stock > 0: - net_stock = -net_stock - if gross_stock > 0: - gross_stock = -gross_stock - - return net_stock, gross_stock - - # Build invoice data with stock values - # Also negate total and net_total for credit notes so frontend sums work correctly - # Only negate if values are positive (some suppliers already use negative values) - invoice_data = {} - for inv in invoices: - net_stock, gross_stock = calc_stock_values(inv) - inv_date = inv.invoice_date or inv.created_at.date() - is_credit = inv.document_type == 'credit_note' - invoice_data[inv.id] = { - "inv": inv, - "date": inv_date, - "net_stock": net_stock, - "gross_stock": gross_stock, - "total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total, - "net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total, - } - - # Organize by supplier - supplier_invoices: dict[tuple, list] = defaultdict(list) - for inv_id, data in invoice_data.items(): - inv = data["inv"] - if inv.supplier_id: - key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) - else: - vendor = inv.vendor_name or "Unknown Supplier" - key = (None, vendor, True) - supplier_invoices[key].append(inv_id) - - # Get ordered list of all suppliers (matched first, then alphabetical) - all_supplier_keys = sorted( - supplier_invoices.keys(), - key=lambda x: (x[2], x[1].lower()) - ) - all_suppliers = [name for (_, name, _) in all_supplier_keys] - - # Calculate period totals (stock and invoice) - # Use stored net_total (already negated for credit notes), fall back to stored total - period_total = sum(data["net_stock"] for data in invoice_data.values()) - period_invoice_total = sum( - (data["net_total"] or data["total"] or Decimal("0")) for data in invoice_data.values() - ) - - # Build weeks - find all weeks that overlap with the date range - weeks_data = [] - - # Find first Monday on or before from_date - first_monday = from_date - timedelta(days=from_date.weekday()) - - current_week_start = first_monday - while current_week_start <= to_date: - week_end = current_week_start + timedelta(days=6) - week_dates = [current_week_start + timedelta(days=i) for i in range(7)] - - # Build supplier rows for this week - week_total = Decimal("0") - week_invoice_total = Decimal("0") - week_daily_totals: dict[str, Decimal] = defaultdict(Decimal) - week_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal) - supplier_data_list: list[tuple] = [] - - for supplier_key in all_supplier_keys: - supplier_id, supplier_name, is_unmatched = supplier_key - inv_ids = supplier_invoices.get(supplier_key, []) - - invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list) - supplier_week_total = Decimal("0") - - for inv_id in inv_ids: - data = invoice_data[inv_id] - inv = data["inv"] - inv_date = data["date"] - - # Only include if in this week - if current_week_start <= inv_date <= week_end: - date_str = inv_date.isoformat() - invoices_by_date[date_str].append(MonthlyPurchaseInvoice( - id=inv.id, - invoice_number=inv.invoice_number, - invoice_date=inv.invoice_date, - total=data["total"], - net_total=data["net_total"], - net_stock=data["net_stock"], - gross_stock=data["gross_stock"], - supplier_match_type=inv.supplier_match_type - )) - supplier_week_total += data["net_stock"] - week_daily_totals[date_str] += data["net_stock"] - # Use stored net_total (already negated for credit notes), fall back to stored total - inv_net = data["net_total"] or data["total"] or Decimal("0") - week_invoice_total += inv_net - week_daily_invoice_totals[date_str] += inv_net - - week_total += supplier_week_total - supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total)) - - # Calculate percentages - week_supplier_rows = [] - for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list: - supplier_id, supplier_name, is_unmatched = supplier_key - percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0") - - week_supplier_rows.append(MonthlySupplierRow( - supplier_id=supplier_id, - supplier_name=supplier_name, - is_unmatched=is_unmatched, - invoices_by_date=invoices_by_date, - total_net_stock=supplier_week_total, - percentage=round(percentage, 1) - )) - - weeks_data.append(WeekData( - week_start=current_week_start, - week_end=week_end, - dates=week_dates, - suppliers=week_supplier_rows, - daily_totals=dict(week_daily_totals), - week_total=week_total, - daily_invoice_totals=dict(week_daily_invoice_totals), - week_invoice_total=week_invoice_total - )) - - current_week_start += timedelta(days=7) - - # Calculate daily totals for entire period - period_daily_totals: dict[str, Decimal] = defaultdict(Decimal) - period_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal) - for data in invoice_data.values(): - date_str = data["date"].isoformat() - period_daily_totals[date_str] += data["net_stock"] - # Use stored net_total (already negated for credit notes), fall back to stored total - period_daily_invoice_totals[date_str] += data["net_total"] or data["total"] or Decimal("0") - - return DateRangePurchasesResponse( - from_date=from_date, - to_date=to_date, - period_label=period_label, - weeks=weeks_data, - all_suppliers=all_suppliers, - daily_totals=dict(period_daily_totals), - period_total=period_total, - daily_invoice_totals=dict(period_daily_invoice_totals), - period_invoice_total=period_invoice_total - ) - - -class MonthlyGPResponse(BaseModel): - """Response for monthly GP calculation""" - year: int - month: int - month_name: str - net_food_sales: Decimal # Newbook revenue + manual entries - net_food_purchases: Decimal # Confirmed invoices net_total sum - gross_profit: Decimal # Sales - Purchases - gross_profit_percent: Decimal # (GP / Sales) * 100 - - -class SupplierBreakdown(BaseModel): - """Supplier purchase breakdown for period""" - supplier_id: int | None - supplier_name: str - net_purchases: Decimal - percentage: Decimal - - -class GLAccountBreakdown(BaseModel): - """GL account revenue breakdown for period""" - gl_account_id: int - gl_account_name: str - net_revenue: Decimal - percentage: Decimal - - -class DateRangeGPResponse(BaseModel): - """Response for date range GP calculation""" - from_date: date - to_date: date - period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026" - net_food_sales: Decimal # Newbook revenue + manual entries - net_food_purchases: Decimal # Confirmed invoices net_total sum - gross_profit: Decimal # Sales - Purchases - gross_profit_percent: Decimal # (GP / Sales) * 100 - supplier_breakdown: list[SupplierBreakdown] = [] - gl_account_breakdown: list[GLAccountBreakdown] = [] - # Allowances breakdown - logbook entry types - wastage_total: Optional[Decimal] = None # Wastage entries - transfer_total: Optional[Decimal] = None # Transfer entries - staff_food_total: Optional[Decimal] = None # Staff food entries - manual_adjustment_total: Optional[Decimal] = None # Manual adjustment entries - # Cost distribution breakdown - cd_deductions_total: Optional[Decimal] = None # Source offsets (negative amounts removing cost from invoice dates) - cd_reallocations_total: Optional[Decimal] = None # Distribution entries (positive amounts adding cost to target dates) - # Open disputes on invoices in this period - disputes_total: Optional[Decimal] = None - - -@router.get("/gp/range", response_model=DateRangeGPResponse) -async def get_gp_by_range( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get GP calculation for a custom date range (inclusive)""" - from models.line_item import LineItem - from models.logbook import LogbookEntry, EntryType - from sqlalchemy import or_ - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build period label - if from_date.year == to_date.year: - if from_date.month == to_date.month: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" - - # Get Newbook revenue for tracked GL accounts - newbook_revenue_result = await db.execute( - select(func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= from_date, - NewbookDailyRevenue.date <= to_date, - NewbookGLAccount.is_tracked == True - ) - ) - newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") - - # Get manual revenue entries for period - manual_revenue_result = await db.execute( - select(func.sum(RevenueEntry.amount)) - .where( - RevenueEntry.kitchen_id == current_user.kitchen_id, - RevenueEntry.date >= from_date, - RevenueEntry.date <= to_date - ) - ) - manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") - - # Total net food sales - net_food_sales = newbook_revenue + manual_revenue - - # Get net purchases from confirmed invoices - stock items only (exclude non-stock) - # Credit notes (document_type='credit_note') are treated as negative purchases - # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total - from sqlalchemy import and_ - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.document_type) - .subquery() - ) - purchases_result = await db.execute( - select(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - net_food_purchases = purchases_result.scalar() or Decimal("0.00") - - # Add cost distribution adjustments — split into deductions (source offsets) and reallocations - cd_deductions_result = await db.execute( - select(func.sum(CostDistributionEntry.amount)) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= from_date, - CostDistributionEntry.entry_date <= to_date, - CostDistributionEntry.is_source_offset == True, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - ) - cd_deductions = cd_deductions_result.scalar() or Decimal("0.00") # Will be negative - - cd_reallocations_result = await db.execute( - select(func.sum(CostDistributionEntry.amount)) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= from_date, - CostDistributionEntry.entry_date <= to_date, - CostDistributionEntry.is_source_offset == False, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - ) - cd_reallocations = cd_reallocations_result.scalar() or Decimal("0.00") # Will be positive - - # net_food_purchases stays as raw invoice total — CD adjustments are - # returned separately so the frontend can toggle them on/off. - # GP is calculated from raw purchases; the frontend applies CD + allowances - # to compute the "Adjusted GP %". - - # Get logbook entry totals by type - # Helper function to query a specific entry type - async def get_entry_type_total(entry_type: EntryType) -> Decimal: - result = await db.execute( - select(func.sum(LogbookEntry.total_cost)) - .where( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.entry_date >= from_date, - LogbookEntry.entry_date <= to_date, - LogbookEntry.entry_type == entry_type, - LogbookEntry.is_deleted == False - ) - ) - return result.scalar() or Decimal("0.00") - - wastage_total = await get_entry_type_total(EntryType.WASTAGE) - transfer_total = await get_entry_type_total(EntryType.TRANSFER) - staff_food_total = await get_entry_type_total(EntryType.STAFF_FOOD) - manual_adjustment_total = await get_entry_type_total(EntryType.MANUAL_ADJUSTMENT) - - # Open disputes total - based on invoice date, not dispute creation date - from models.dispute import InvoiceDispute, DisputeStatus - open_statuses = [ - DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, - DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, - DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED - ] - disputes_result = await db.execute( - select(func.sum(InvoiceDispute.difference_amount)) - .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) - .where( - InvoiceDispute.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - InvoiceDispute.status.in_(open_statuses) - ) - ) - disputes_total = disputes_result.scalar() or Decimal("0.00") - - # Calculate GP - gross_profit = net_food_sales - net_food_purchases - gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00") - - # Get supplier breakdown for purchases (credit notes as negative) - # Use subquery to sum per invoice, then aggregate by supplier - from models.supplier import Supplier - invoice_by_supplier_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.supplier_id.label('supplier_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type) - .subquery() - ) - supplier_result = await db.execute( - select( - invoice_by_supplier_subq.c.supplier_id, - Supplier.name, - func.sum( - case( - (and_(invoice_by_supplier_subq.c.doc_type == 'credit_note', - invoice_by_supplier_subq.c.stock_total > 0), - -invoice_by_supplier_subq.c.stock_total), - else_=invoice_by_supplier_subq.c.stock_total - ) - ) - ) - .select_from(invoice_by_supplier_subq) - .outerjoin(Supplier, invoice_by_supplier_subq.c.supplier_id == Supplier.id) - .group_by(invoice_by_supplier_subq.c.supplier_id, Supplier.name) - .order_by(func.sum( - case( - (and_(invoice_by_supplier_subq.c.doc_type == 'credit_note', - invoice_by_supplier_subq.c.stock_total > 0), - -invoice_by_supplier_subq.c.stock_total), - else_=invoice_by_supplier_subq.c.stock_total - ) - ).desc()) - ) - supplier_rows = supplier_result.all() - supplier_breakdown = [] - for supplier_id, supplier_name, total in supplier_rows: - if total and total != 0: # Include negative totals (net credit notes) - pct = (total / net_food_purchases * 100) if net_food_purchases > 0 else Decimal("0") - supplier_breakdown.append(SupplierBreakdown( - supplier_id=supplier_id, - supplier_name=supplier_name or "Unmatched", - net_purchases=total, - percentage=round(pct, 1) - )) - - # Get GL account breakdown for revenue - gl_result = await db.execute( - select(NewbookGLAccount.id, NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= from_date, - NewbookDailyRevenue.date <= to_date, - NewbookGLAccount.is_tracked == True - ) - .group_by(NewbookGLAccount.id, NewbookGLAccount.gl_name) - .order_by(func.sum(NewbookDailyRevenue.amount_net).desc()) - ) - gl_rows = gl_result.all() - gl_breakdown = [] - for gl_id, gl_name, total in gl_rows: - if total: # Include all non-zero values (including negative discounts) - pct = (total / newbook_revenue * 100) if newbook_revenue > 0 else Decimal("0") - gl_breakdown.append(GLAccountBreakdown( - gl_account_id=gl_id, - gl_account_name=gl_name or "Unknown", - net_revenue=total, - percentage=round(pct, 1) - )) - - return DateRangeGPResponse( - from_date=from_date, - to_date=to_date, - period_label=period_label, - net_food_sales=net_food_sales, - net_food_purchases=net_food_purchases, - gross_profit=gross_profit, - gross_profit_percent=round(gross_profit_percent, 1), - supplier_breakdown=supplier_breakdown, - gl_account_breakdown=gl_breakdown, - wastage_total=wastage_total if wastage_total > 0 else None, - transfer_total=transfer_total if transfer_total > 0 else None, - staff_food_total=staff_food_total if staff_food_total > 0 else None, - manual_adjustment_total=manual_adjustment_total if manual_adjustment_total > 0 else None, - disputes_total=disputes_total if disputes_total > 0 else None, - cd_deductions_total=cd_deductions if cd_deductions != 0 else None, - cd_reallocations_total=cd_reallocations if cd_reallocations != 0 else None, - ) - - -class DailyDataPoint(BaseModel): - """Single day's data for charting""" - date: date - net_sales: Decimal - net_purchases: Decimal - occupancy: int | None = None # Night total occupancy (from Newbook when available) - lunch_covers: int | None = None # Placeholder for resos integration - dinner_covers: int | None = None # Placeholder for resos integration - total_covers: int | None = None # Placeholder for resos integration - - -class DailyGPChartResponse(BaseModel): - """Response for daily GP chart data""" - from_date: date - to_date: date - data: list[DailyDataPoint] - - -@router.get("/gp/daily", response_model=DailyGPChartResponse) -async def get_daily_gp_data( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily net sales and purchases for charting""" - from models.line_item import LineItem - from models.resos import ResosDailyStats, ResosBooking - from sqlalchemy import or_, and_ - from datetime import timedelta - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Get daily Newbook revenue (grouped by date) - newbook_daily = await db.execute( - select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= from_date, - NewbookDailyRevenue.date <= to_date, - NewbookGLAccount.is_tracked == True - ) - .group_by(NewbookDailyRevenue.date) - ) - newbook_by_date = {row[0]: row[1] or Decimal("0") for row in newbook_daily.all()} - - # Get daily manual revenue entries (grouped by date) - manual_daily = await db.execute( - select(RevenueEntry.date, func.sum(RevenueEntry.amount)) - .where( - RevenueEntry.kitchen_id == current_user.kitchen_id, - RevenueEntry.date >= from_date, - RevenueEntry.date <= to_date - ) - .group_by(RevenueEntry.date) - ) - manual_by_date = {row[0]: row[1] or Decimal("0") for row in manual_daily.all()} - - # Get daily purchases from confirmed invoices (grouped by invoice_date) - # Credit notes (document_type='credit_note') are treated as negative purchases - # Use subquery to sum per invoice, then aggregate by date - invoice_daily_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.invoice_date.label('inv_date'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.invoice_date, Invoice.document_type) - .subquery() - ) - purchases_daily = await db.execute( - select( - invoice_daily_subq.c.inv_date, - func.sum( - case( - (and_(invoice_daily_subq.c.doc_type == 'credit_note', - invoice_daily_subq.c.stock_total > 0), - -invoice_daily_subq.c.stock_total), - else_=invoice_daily_subq.c.stock_total - ) - ) - ) - .select_from(invoice_daily_subq) - .group_by(invoice_daily_subq.c.inv_date) - ) - purchases_by_date = {row[0]: row[1] or Decimal("0") for row in purchases_daily.all()} - - # Add cost distribution adjustments grouped by date - cd_daily = await db.execute( - select( - CostDistributionEntry.entry_date, - func.sum(CostDistributionEntry.amount) - ) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= from_date, - CostDistributionEntry.entry_date <= to_date, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - .group_by(CostDistributionEntry.entry_date) - ) - for row in cd_daily.all(): - cd_date, cd_amount = row[0], row[1] or Decimal("0") - purchases_by_date[cd_date] = purchases_by_date.get(cd_date, Decimal("0")) + cd_amount - - # Get Resos booking data (covers by service period) - resos_daily = await db.execute( - select(ResosDailyStats) - .where( - and_( - ResosDailyStats.kitchen_id == current_user.kitchen_id, - ResosDailyStats.date >= from_date, - ResosDailyStats.date <= to_date - ) - ) - ) - resos_stats = {stat.date: stat for stat in resos_daily.scalars().all()} - - # Get Newbook occupancy data (total guests per night) - newbook_occupancy = await db.execute( - select(NewbookDailyOccupancy) - .where( - and_( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= from_date, - NewbookDailyOccupancy.date <= to_date - ) - ) - ) - occupancy_by_date = {occ.date: occ for occ in newbook_occupancy.scalars().all()} - - # Build daily data points for the entire range - data_points = [] - current_date = from_date - while current_date <= to_date: - net_sales = (newbook_by_date.get(current_date, Decimal("0")) + - manual_by_date.get(current_date, Decimal("0"))) - net_purchases = purchases_by_date.get(current_date, Decimal("0")) - - # Extract Resos covers if available - lunch_covers = None - dinner_covers = None - total_covers = None - - if current_date in resos_stats: - stat = resos_stats[current_date] - total_covers = stat.total_covers - - # Extract lunch and dinner covers from service_breakdown - if stat.service_breakdown: - for service in stat.service_breakdown: - period_name = service.get('period', '').lower() - covers = service.get('covers', 0) - - if 'lunch' in period_name: - lunch_covers = covers - elif 'dinner' in period_name: - dinner_covers = covers - - # Extract Newbook occupancy (total guests) if available - occupancy = None - if current_date in occupancy_by_date: - occ = occupancy_by_date[current_date] - occupancy = occ.total_guests - - data_points.append(DailyDataPoint( - date=current_date, - net_sales=net_sales, - net_purchases=net_purchases, - occupancy=occupancy, - lunch_covers=lunch_covers, - dinner_covers=dinner_covers, - total_covers=total_covers - )) - current_date += timedelta(days=1) - - return DailyGPChartResponse( - from_date=from_date, - to_date=to_date, - data=data_points - ) - - -@router.get("/gp/monthly", response_model=MonthlyGPResponse) -async def get_monthly_gp( - year: int | None = None, - month: int | None = None, # 1-12 - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get monthly GP calculation with sales and purchases breakdown""" - from calendar import monthrange, month_name as calendar_month_name - - # Default to current month - today = date.today() - year = year or today.year - month = month or today.month - - # Get first and last day of month - _, days_in_month = monthrange(year, month) - month_start = date(year, month, 1) - month_end = date(year, month, days_in_month) - - # Get Newbook revenue for tracked GL accounts - newbook_revenue_result = await db.execute( - select(func.sum(NewbookDailyRevenue.amount_net)) - .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) - .where( - NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, - NewbookDailyRevenue.date >= month_start, - NewbookDailyRevenue.date <= month_end, - NewbookGLAccount.is_tracked == True - ) - ) - newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") - - # Get manual revenue entries for period - manual_revenue_result = await db.execute( - select(func.sum(RevenueEntry.amount)) - .where( - RevenueEntry.kitchen_id == current_user.kitchen_id, - RevenueEntry.date >= month_start, - RevenueEntry.date <= month_end - ) - ) - manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") - - # Total net food sales - net_food_sales = newbook_revenue + manual_revenue - - # Get net purchases from confirmed invoices - stock items only (exclude non-stock) - # Credit notes (document_type='credit_note') are treated as negative purchases - # Use subquery to sum per invoice, then conditionally negate - from models.line_item import LineItem - from sqlalchemy import or_, and_ - - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= month_start, - Invoice.invoice_date <= month_end, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.document_type) - .subquery() - ) - purchases_result = await db.execute( - select(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - net_food_purchases = purchases_result.scalar() or Decimal("0.00") - - # Add cost distribution adjustments - cd_adj_result = await db.execute( - select(func.sum(CostDistributionEntry.amount)) - .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) - .where( - CostDistributionEntry.kitchen_id == current_user.kitchen_id, - CostDistributionEntry.entry_date >= month_start, - CostDistributionEntry.entry_date <= month_end, - CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), - ) - ) - net_food_purchases += cd_adj_result.scalar() or Decimal("0.00") - - # Calculate GP - gross_profit = net_food_sales - net_food_purchases - gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00") - - return MonthlyGPResponse( - year=year, - month=month, - month_name=calendar_month_name[month], - net_food_sales=net_food_sales, - net_food_purchases=net_food_purchases, - gross_profit=gross_profit, - gross_profit_percent=round(gross_profit_percent, 1) - ) - - -# ============ Top Sellers Models ============ - -class TopSellerItem(BaseModel): - """Individual top seller item""" - item_name: str - qty: int - revenue: Decimal - - -class PackageFavoriteItem(BaseModel): - """Package guest favorite item (qty only)""" - item_name: str - qty: int - - -class CategoryTopSellers(BaseModel): - """Top sellers for a single category""" - category: str # "Starters", "Mains", "Desserts", etc. - top_by_qty: list[TopSellerItem] # Top 10 by quantity - top_by_revenue: list[TopSellerItem] # Top 10 by revenue - - -class TopSellersResponse(BaseModel): - """Response for top sellers data""" - from_date: date - to_date: date - source: str = "newbook" # "sambapos" or "newbook" - # SambaPOS category-based format - categories: list[CategoryTopSellers] = [] - # Legacy Newbook format (flat lists) - top_by_qty: list[TopSellerItem] = [] - top_by_revenue: list[TopSellerItem] = [] - package_favorites: list[PackageFavoriteItem] = [] - total_charges_processed: int = 0 - total_items_aggregated: int = 0 - - -def parse_charge_description(description: str) -> tuple[int, str] | None: - """ - Parse Newbook charge description to extract qty and item name. - - Format: "Ticket: 22900 - 1 x Venison Bourguignon" - Returns: (qty, item_name) or None if cannot parse - """ - import re - - if not description: - return None - - # Try pattern: "Ticket: XXXXX - N x Item Name" - # Also handle variations without ticket number - patterns = [ - r'Ticket:\s*\d+\s*-\s*(\d+)\s*x\s*(.+)', # Ticket: 22900 - 1 x Item - r'^(\d+)\s*x\s*(.+)', # 1 x Item (no ticket prefix) - r'-\s*(\d+)\s*x\s*(.+)', # - 1 x Item - ] - - for pattern in patterns: - match = re.search(pattern, description, re.IGNORECASE) - if match: - try: - qty = int(match.group(1)) - item_name = match.group(2).strip() - # Clean up item name - remove trailing punctuation and whitespace - item_name = re.sub(r'[\s,;.]+$', '', item_name) - if item_name and qty > 0: - return (qty, item_name) - except (ValueError, IndexError): - continue - - return None - - -@router.get("/gp/top-sellers", response_model=TopSellersResponse) -async def get_top_sellers( - from_date: date, - to_date: date, - limit: int = 10, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get top selling items for a date range. - - If SambaPOS is configured, fetches data from SambaPOS database with category breakdowns. - Otherwise falls back to Newbook charges with flat lists. - - Returns top 10 items by quantity and top 10 by revenue (per category for SambaPOS). - """ - from models.settings import KitchenSettings - from models.newbook import NewbookGLAccount - from services.newbook_api import NewbookAPIClient, NewbookAPIError - from services.sambapos_api import SambaPOSClient - from collections import defaultdict - import logging - logger = logging.getLogger(__name__) - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Get settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=400, detail="Settings not configured") - - # Check if SambaPOS is configured - use it if available - if all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - # Use SambaPOS data source - logger.info(f"Top sellers: Using SambaPOS data source for {from_date} to {to_date}") - - # Get tracked categories - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - # Get excluded items - excluded_items = [] - if settings.sambapos_excluded_items: - excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] - - if not tracked_categories: - # Return empty response if no categories configured - return TopSellersResponse( - from_date=from_date, - to_date=to_date, - source="sambapos", - categories=[] - ) - - try: - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - # Get top sellers by quantity and by revenue (excluding configured GroupCodes) - top_by_qty = await client.get_top_sellers(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None) - top_by_revenue = await client.get_top_sellers_by_revenue(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None) - - # Build category response in order of tracked_categories - categories_response = [] - for cat_name in tracked_categories: - qty_items = top_by_qty.get(cat_name, []) - rev_items = top_by_revenue.get(cat_name, []) - - categories_response.append(CategoryTopSellers( - category=cat_name, - top_by_qty=[ - TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"]) - for item in qty_items - ], - top_by_revenue=[ - TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"]) - for item in rev_items - ] - )) - - return TopSellersResponse( - from_date=from_date, - to_date=to_date, - source="sambapos", - categories=categories_response - ) - - except Exception as e: - logger.error(f"SambaPOS top sellers failed: {e}") - raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") - - # Fallback to Newbook if SambaPOS not configured - if not settings.newbook_api_username: - raise HTTPException(status_code=400, detail="Neither SambaPOS nor Newbook credentials configured") - - # Get tracked GL accounts for this kitchen (food sales accounts) - gl_result = await db.execute( - select(NewbookGLAccount).where( - NewbookGLAccount.kitchen_id == current_user.kitchen_id, - NewbookGLAccount.is_tracked == True - ) - ) - tracked_accounts = gl_result.scalars().all() - - if not tracked_accounts: - return TopSellersResponse( - from_date=from_date, - to_date=to_date, - top_by_qty=[], - top_by_revenue=[], - total_charges_processed=0, - total_items_aggregated=0 - ) - - # Build set of tracked GL account IDs (as strings) - tracked_gl_ids = {acc.gl_account_id for acc in tracked_accounts} - - # Fetch charges from Newbook - try: - async with NewbookAPIClient( - username=settings.newbook_api_username, - password=settings.newbook_api_password, - api_key=settings.newbook_api_key, - region=settings.newbook_api_region or "au", - instance_id=settings.newbook_instance_id - ) as client: - charges = await client.get_charges_list(from_date, to_date) - except NewbookAPIError as e: - raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") - - # Aggregate items - separate tracking for regular vs package/supplement - import re - import logging - logger = logging.getLogger(__name__) - - regular_items: dict[str, dict] = defaultdict(lambda: {"qty": 0, "revenue": Decimal("0")}) - package_items: dict[str, int] = defaultdict(int) # qty only for package/supplement - total_processed = 0 - total_voided = 0 - total_wrong_gl = 0 - total_unparsed = 0 - sample_unparsed = [] - sample_charges = [] - - logger.info(f"Top sellers: Starting with {len(charges)} total charges, {len(tracked_gl_ids)} tracked GL IDs: {tracked_gl_ids}") - - for charge in charges: - # Log first few charges to see structure - if len(sample_charges) < 5: - sample_charges.append({ - "gl_account_id": charge.get("gl_account_id"), - "description": charge.get("description"), - "voided_when": charge.get("voided_when"), - "voided_by": charge.get("voided_by"), - }) - - # Skip voided charges - voided_when = charge.get("voided_when") - voided_by = charge.get("voided_by", "0") - - # Check if voided - voided_when can be None, empty string, or actual date - # voided_by is "0" when not voided - if voided_when or (voided_by and voided_by != "0"): - total_voided += 1 - continue - - # Filter to tracked GL accounts only - gl_account_id = charge.get("gl_account_id", "") - if gl_account_id not in tracked_gl_ids: - total_wrong_gl += 1 - continue - - total_processed += 1 - - # Parse description to get qty and item name - description = charge.get("description", "") - parsed = parse_charge_description(description) - - if parsed: - qty, item_name = parsed - amount = charge.get("amount_ex_tax", Decimal("0")) - - # Check for [Package] or [Supplement] suffix - is_package = bool(re.search(r'\[(package|supplement)\]', item_name, re.IGNORECASE)) - - # Strip [Package] or [Supplement] suffix - item_name = re.sub(r'\s*\[(package|supplement)\]\s*', '', item_name, flags=re.IGNORECASE) - - # Normalize item name: lowercase, remove extra spaces - item_name = " ".join(item_name.lower().split()) - - if is_package: - # Track package items separately (qty only) - package_items[item_name] += qty - else: - # Regular item - track qty and revenue - regular_items[item_name]["qty"] += qty - regular_items[item_name]["revenue"] += amount - else: - total_unparsed += 1 - if len(sample_unparsed) < 10: - sample_unparsed.append(description) - - # Calculate average prices from regular sales - avg_prices: dict[str, Decimal] = {} - for name, data in regular_items.items(): - if data["qty"] > 0: - avg_prices[name] = data["revenue"] / data["qty"] - - # Combine regular + package for main top sellers - # For package items, use average price from regular sales if available - all_item_names = set(regular_items.keys()) | set(package_items.keys()) - combined_items: dict[str, dict] = {} - - for name in all_item_names: - reg_qty = regular_items.get(name, {}).get("qty", 0) - pkg_qty = package_items.get(name, 0) - total_qty = reg_qty + pkg_qty - - # Revenue: regular revenue + (package qty * avg price if available) - reg_revenue = regular_items.get(name, {}).get("revenue", Decimal("0")) - if name in avg_prices and pkg_qty > 0: - estimated_pkg_revenue = avg_prices[name] * pkg_qty - total_revenue = reg_revenue + estimated_pkg_revenue - else: - total_revenue = reg_revenue - - combined_items[name] = {"qty": total_qty, "revenue": total_revenue} - - logger.info(f"Top sellers: processed {total_processed} charges, {len(regular_items)} regular items, {len(package_items)} package items, {len(combined_items)} combined") - - # Sort and get top items from combined - items_list = [ - {"name": name, "qty": data["qty"], "revenue": data["revenue"]} - for name, data in combined_items.items() - ] - - # Top by quantity - top_by_qty = sorted(items_list, key=lambda x: x["qty"], reverse=True)[:limit] - - # Top by revenue - top_by_revenue = sorted(items_list, key=lambda x: x["revenue"], reverse=True)[:limit] - - # Package favorites (qty only, from package_items) - package_favorites_list = sorted( - [{"name": name, "qty": qty} for name, qty in package_items.items()], - key=lambda x: x["qty"], - reverse=True - )[:limit] - - # Title case helper for display - def title_case(s: str) -> str: - return " ".join(word.capitalize() for word in s.split()) - - return TopSellersResponse( - from_date=from_date, - to_date=to_date, - source="newbook", - top_by_qty=[ - TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"]) - for item in top_by_qty - ], - top_by_revenue=[ - TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"]) - for item in top_by_revenue - ], - package_favorites=[ - PackageFavoriteItem(item_name=title_case(item["name"]), qty=item["qty"]) - for item in package_favorites_list - ], - total_charges_processed=total_processed, - total_items_aggregated=len(combined_items) - ) - - -# ============ Purchases Report Endpoints ============ - -class PurchasesSummaryResponse(BaseModel): - """Response for purchases summary""" - from_date: date - to_date: date - period_label: str - total_purchases: Decimal - supplier_breakdown: list[SupplierBreakdown] - - -class DailySupplierDataPoint(BaseModel): - """Single data point for daily supplier chart""" - date: date - supplier_id: int | None - supplier_name: str - net_purchases: Decimal - - -class DailySupplierChartResponse(BaseModel): - """Response for daily supplier chart""" - from_date: date - to_date: date - suppliers: list[str] # Ordered list of supplier names for legend - data: list[DailySupplierDataPoint] - - -class TopLineItem(BaseModel): - """Top line item by quantity or value""" - description: str - product_code: str | None - total_quantity: Decimal - total_value: Decimal - avg_unit_price: Decimal - occurrence_count: int - - -class TopItemsResponse(BaseModel): - """Response for top line items""" - from_date: date - to_date: date - top_by_quantity: list[TopLineItem] - top_by_value: list[TopLineItem] - - -@router.get("/purchases/summary", response_model=PurchasesSummaryResponse) -async def get_purchases_summary( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get purchases summary with supplier breakdown for date range""" - from models.line_item import LineItem - from models.supplier import Supplier - from sqlalchemy import or_ - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build period label - if from_date.year == to_date.year: - if from_date.month == to_date.month: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" - - # Get total purchases (stock items only from confirmed invoices) - # Credit notes (document_type='credit_note') are treated as negative purchases - # Use subquery to sum per invoice, then conditionally negate - from sqlalchemy import and_ - invoice_stock_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.supplier_id.label('supplier_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type) - .subquery() - ) - total_result = await db.execute( - select(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - )) - .select_from(invoice_stock_subq) - ) - total_purchases = total_result.scalar() or Decimal("0.00") - - # Get supplier breakdown (credit notes as negative) - # Reuse the subquery grouped by supplier - supplier_result = await db.execute( - select( - invoice_stock_subq.c.supplier_id, - Supplier.name, - func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - ) - ) - .select_from(invoice_stock_subq) - .outerjoin(Supplier, invoice_stock_subq.c.supplier_id == Supplier.id) - .group_by(invoice_stock_subq.c.supplier_id, Supplier.name) - .order_by(func.sum( - case( - (and_(invoice_stock_subq.c.doc_type == 'credit_note', - invoice_stock_subq.c.stock_total > 0), - -invoice_stock_subq.c.stock_total), - else_=invoice_stock_subq.c.stock_total - ) - ).desc()) - ) - - supplier_breakdown = [] - for supplier_id, supplier_name, total in supplier_result.all(): - if total and total != 0: # Include negative totals (net credit notes) - pct = (total / total_purchases * 100) if total_purchases > 0 else Decimal("0") - supplier_breakdown.append(SupplierBreakdown( - supplier_id=supplier_id, - supplier_name=supplier_name or "Unmatched", - net_purchases=total, - percentage=round(pct, 1) - )) - - return PurchasesSummaryResponse( - from_date=from_date, - to_date=to_date, - period_label=period_label, - total_purchases=total_purchases, - supplier_breakdown=supplier_breakdown - ) - - -@router.get("/purchases/daily-by-supplier", response_model=DailySupplierChartResponse) -async def get_daily_purchases_by_supplier( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily purchases grouped by supplier for multi-line chart""" - from models.line_item import LineItem - from models.supplier import Supplier - from sqlalchemy import or_ - from datetime import timedelta - from collections import defaultdict - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Get daily purchases by supplier (credit notes as negative) - # Use subquery to sum per invoice first, then aggregate by date/supplier - from sqlalchemy import and_ - invoice_daily_subq = ( - select( - Invoice.id.label('inv_id'), - Invoice.invoice_date.label('inv_date'), - Invoice.supplier_id.label('supplier_id'), - Invoice.document_type.label('doc_type'), - func.sum(LineItem.amount).label('stock_total') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - .group_by(Invoice.id, Invoice.invoice_date, Invoice.supplier_id, Invoice.document_type) - .subquery() - ) - daily_result = await db.execute( - select( - invoice_daily_subq.c.inv_date, - invoice_daily_subq.c.supplier_id, - Supplier.name, - func.sum( - case( - (and_(invoice_daily_subq.c.doc_type == 'credit_note', - invoice_daily_subq.c.stock_total > 0), - -invoice_daily_subq.c.stock_total), - else_=invoice_daily_subq.c.stock_total - ) - ) - ) - .select_from(invoice_daily_subq) - .outerjoin(Supplier, invoice_daily_subq.c.supplier_id == Supplier.id) - .group_by(invoice_daily_subq.c.inv_date, invoice_daily_subq.c.supplier_id, Supplier.name) - .order_by(invoice_daily_subq.c.inv_date) - ) - - # Collect all supplier totals to determine top suppliers - supplier_totals: dict[str, Decimal] = defaultdict(Decimal) - daily_data: list[tuple] = [] - - for inv_date, supplier_id, supplier_name, total in daily_result.all(): - name = supplier_name or "Unmatched" - supplier_totals[name] += total or Decimal("0") - daily_data.append((inv_date, supplier_id, name, total or Decimal("0"))) - - # Get top 10 suppliers by total purchases - top_suppliers = sorted(supplier_totals.keys(), key=lambda x: supplier_totals[x], reverse=True)[:10] - - # Build data points for top suppliers only - data_points = [] - for inv_date, supplier_id, supplier_name, total in daily_data: - if supplier_name in top_suppliers: - data_points.append(DailySupplierDataPoint( - date=inv_date, - supplier_id=supplier_id, - supplier_name=supplier_name, - net_purchases=total - )) - - return DailySupplierChartResponse( - from_date=from_date, - to_date=to_date, - suppliers=top_suppliers, - data=data_points - ) - - -@router.get("/purchases/top-items", response_model=TopItemsResponse) -async def get_top_purchase_items( - from_date: date, - to_date: date, - limit: int = 10, - supplier_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get top line items by quantity and value, optionally filtered by supplier""" - from models.line_item import LineItem - from sqlalchemy import or_ - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build base query (credit notes as negative values) - # Use -func.abs() to handle suppliers who already use negative values (avoid double-negation) - query = ( - select( - LineItem.description, - LineItem.product_code, - func.sum( - case( - (Invoice.document_type == 'credit_note', -func.abs(LineItem.quantity)), - else_=LineItem.quantity - ) - ).label('total_qty'), - func.sum( - case( - (Invoice.document_type == 'credit_note', -func.abs(LineItem.amount)), - else_=LineItem.amount - ) - ).label('total_value'), - func.avg(LineItem.unit_price).label('avg_price'), - func.count(LineItem.id).label('occurrence_count') - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date, - Invoice.status == InvoiceStatus.CONFIRMED, - LineItem.amount.isnot(None), - LineItem.description.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) - ) - ) - - # Add supplier filter if specified - if supplier_id is not None: - query = query.where(Invoice.supplier_id == supplier_id) - - query = query.group_by(LineItem.description, LineItem.product_code) - - items_result = await db.execute(query) - - all_items = [] - for row in items_result.all(): - desc, prod_code, total_qty, total_value, avg_price, count = row - if total_qty and total_qty > 0: - all_items.append({ - "description": desc or "Unknown", - "product_code": prod_code, - "total_quantity": total_qty, - "total_value": total_value or Decimal("0"), - "avg_unit_price": avg_price or Decimal("0"), - "occurrence_count": count - }) - - # Sort by quantity - top_by_qty = sorted(all_items, key=lambda x: x["total_quantity"], reverse=True)[:limit] - - # Sort by value - top_by_value = sorted(all_items, key=lambda x: x["total_value"], reverse=True)[:limit] - - return TopItemsResponse( - from_date=from_date, - to_date=to_date, - top_by_quantity=[ - TopLineItem( - description=item["description"], - product_code=item["product_code"], - total_quantity=item["total_quantity"], - total_value=item["total_value"], - avg_unit_price=round(item["avg_unit_price"], 2), - occurrence_count=item["occurrence_count"] - ) - for item in top_by_qty - ], - top_by_value=[ - TopLineItem( - description=item["description"], - product_code=item["product_code"], - total_quantity=item["total_quantity"], - total_value=item["total_value"], - avg_unit_price=round(item["avg_unit_price"], 2), - occurrence_count=item["occurrence_count"] - ) - for item in top_by_value - ] - ) - - -# ============ Allowances Report Endpoints ============ - -class AllowancesSummaryResponse(BaseModel): - """Response for allowances summary""" - from_date: date - to_date: date - period_label: str - wastage_total: Decimal - wastage_count: int - transfer_total: Decimal - transfer_count: int - staff_food_total: Decimal - staff_food_count: int - manual_adjustment_total: Decimal - manual_adjustment_count: int - total_allowances: Decimal - - -class DailyAllowanceDataPoint(BaseModel): - """Single data point for daily allowances chart""" - date: date - wastage: Decimal - transfer: Decimal - staff_food: Decimal - manual_adjustment: Decimal - - -class DailyAllowanceChartResponse(BaseModel): - """Response for daily allowances chart""" - from_date: date - to_date: date - data: list[DailyAllowanceDataPoint] - - -class DisputeTallyRow(BaseModel): - """Single row in dispute tally""" - label: str - count: int - difference_value: Decimal - - -class DisputesSummaryResponse(BaseModel): - """Response for disputes period summary""" - from_date: date - to_date: date - period_label: str - rows: list[DisputeTallyRow] - - -@router.get("/allowances/summary", response_model=AllowancesSummaryResponse) -async def get_allowances_summary( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get allowances summary by entry type""" - from models.logbook import LogbookEntry, EntryType - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build period label - if from_date.year == to_date.year: - if from_date.month == to_date.month: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" - - # Helper to get total and count for entry type - async def get_entry_stats(entry_type: EntryType) -> tuple[Decimal, int]: - total_result = await db.execute( - select(func.sum(LogbookEntry.total_cost), func.count(LogbookEntry.id)) - .where( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.entry_date >= from_date, - LogbookEntry.entry_date <= to_date, - LogbookEntry.entry_type == entry_type, - LogbookEntry.is_deleted == False - ) - ) - row = total_result.one() - return (row[0] or Decimal("0.00"), row[1] or 0) - - wastage_total, wastage_count = await get_entry_stats(EntryType.WASTAGE) - transfer_total, transfer_count = await get_entry_stats(EntryType.TRANSFER) - staff_food_total, staff_food_count = await get_entry_stats(EntryType.STAFF_FOOD) - manual_adj_total, manual_adj_count = await get_entry_stats(EntryType.MANUAL_ADJUSTMENT) - - total_allowances = wastage_total + transfer_total + staff_food_total + manual_adj_total - - return AllowancesSummaryResponse( - from_date=from_date, - to_date=to_date, - period_label=period_label, - wastage_total=wastage_total, - wastage_count=wastage_count, - transfer_total=transfer_total, - transfer_count=transfer_count, - staff_food_total=staff_food_total, - staff_food_count=staff_food_count, - manual_adjustment_total=manual_adj_total, - manual_adjustment_count=manual_adj_count, - total_allowances=total_allowances - ) - - -@router.get("/allowances/daily", response_model=DailyAllowanceChartResponse) -async def get_daily_allowances( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get daily allowances breakdown by type for chart""" - from models.logbook import LogbookEntry, EntryType - from datetime import timedelta - from collections import defaultdict - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Get all entries grouped by date and type - result = await db.execute( - select(LogbookEntry.entry_date, LogbookEntry.entry_type, func.sum(LogbookEntry.total_cost)) - .where( - LogbookEntry.kitchen_id == current_user.kitchen_id, - LogbookEntry.entry_date >= from_date, - LogbookEntry.entry_date <= to_date, - LogbookEntry.is_deleted == False - ) - .group_by(LogbookEntry.entry_date, LogbookEntry.entry_type) - ) - - # Build lookup by date and type - daily_data: dict[date, dict[str, Decimal]] = defaultdict(lambda: { - "wastage": Decimal("0"), - "transfer": Decimal("0"), - "staff_food": Decimal("0"), - "manual_adjustment": Decimal("0") - }) - - for entry_date, entry_type, total in result.all(): - type_key = entry_type.value.lower() - daily_data[entry_date][type_key] = total or Decimal("0") - - # Build data points for full date range - data_points = [] - current_date = from_date - while current_date <= to_date: - day_data = daily_data.get(current_date, { - "wastage": Decimal("0"), - "transfer": Decimal("0"), - "staff_food": Decimal("0"), - "manual_adjustment": Decimal("0") - }) - data_points.append(DailyAllowanceDataPoint( - date=current_date, - wastage=day_data["wastage"], - transfer=day_data["transfer"], - staff_food=day_data["staff_food"], - manual_adjustment=day_data["manual_adjustment"] - )) - current_date += timedelta(days=1) - - return DailyAllowanceChartResponse( - from_date=from_date, - to_date=to_date, - data=data_points - ) - - -@router.get("/disputes/period-summary", response_model=DisputesSummaryResponse) -async def get_disputes_period_summary( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get disputes summary for cases opened in period (by invoice date)""" - from models.dispute import InvoiceDispute, DisputeStatus - - # Validate date range - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Build period label - if from_date.year == to_date.year: - if from_date.month == to_date.month: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" - else: - period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" - - # Base query - disputes where invoice_date falls in period - base_query = ( - select(func.count(InvoiceDispute.id), func.sum(InvoiceDispute.difference_amount)) - .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) - .where( - InvoiceDispute.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= from_date, - Invoice.invoice_date <= to_date - ) - ) - - # Total cases - total_result = await db.execute(base_query) - total_row = total_result.one() - total_count = total_row[0] or 0 - total_value = total_row[1] or Decimal("0") - - # Resolved cases - resolved_result = await db.execute( - base_query.where(InvoiceDispute.status == DisputeStatus.RESOLVED) - ) - resolved_row = resolved_result.one() - resolved_count = resolved_row[0] or 0 - resolved_value = resolved_row[1] or Decimal("0") - - # Closed cases - closed_result = await db.execute( - base_query.where(InvoiceDispute.status == DisputeStatus.CLOSED) - ) - closed_row = closed_result.one() - closed_count = closed_row[0] or 0 - closed_value = closed_row[1] or Decimal("0") - - # Still open cases - open_statuses = [ - DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, - DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, - DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED - ] - open_result = await db.execute( - base_query.where(InvoiceDispute.status.in_(open_statuses)) - ) - open_row = open_result.one() - open_count = open_row[0] or 0 - open_value = open_row[1] or Decimal("0") - - return DisputesSummaryResponse( - from_date=from_date, - to_date=to_date, - period_label=period_label, - rows=[ - DisputeTallyRow(label="Total Cases", count=total_count, difference_value=total_value), - DisputeTallyRow(label="Resolved", count=resolved_count, difference_value=resolved_value), - DisputeTallyRow(label="Closed", count=closed_count, difference_value=closed_value), - DisputeTallyRow(label="Still Open", count=open_count, difference_value=open_value) - ] - ) - - -# ============ Sales GP Report ============ - -class SalesGPItem(BaseModel): - menu_item_name: str - portion_name: str - category: str # SambaPOS Kitchen Course - total_qty: int - total_revenue_net: Decimal # ex-VAT (gross / 1.20) - dbb_qty: int = 0 # Qty from DBB/package orders (price zeroed, original price used) - recipe_id: Optional[int] = None - recipe_name: Optional[str] = None - dish_course: Optional[str] = None # MenuSection name from recipe - cost_per_portion: Optional[Decimal] = None - total_cost: Optional[Decimal] = None - item_gp_percent: Optional[Decimal] = None - - -class SalesGPCourseGroup(BaseModel): - course_name: str - items: list[SalesGPItem] - course_revenue: Decimal - course_cost: Decimal - course_gp_percent: Optional[Decimal] = None - - -class SalesGPResponse(BaseModel): - from_date: date - to_date: date - courses: list[SalesGPCourseGroup] - unmapped_items: list[SalesGPItem] - # GP totals (mapped items ONLY) - mapped_revenue_net: Decimal - mapped_total_cost: Decimal - mapped_gp_percent: Optional[Decimal] = None - # Coverage context - total_all_revenue_net: Decimal - unmapped_revenue_net: Decimal - mapped_revenue_percent: Decimal - mapped_item_count: int - unmapped_item_count: int - - -@router.get("/sales-gp", response_model=SalesGPResponse) -async def get_sales_gp( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Estimated Sales GP% report. - - Fetches SambaPOS sales data for a date range, matches items to dish recipes, - and calculates GP based on recipe costs. Only mapped items contribute to GP calculation. - Unmapped items are listed separately with their revenue for coverage assessment. - """ - from models.settings import KitchenSettings - from models.recipe import Recipe, MenuSection, RecipeCostSnapshot - from services.sambapos_api import SambaPOSClient - - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - # Get settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - if not settings: - raise HTTPException(status_code=400, detail="Settings not configured") - - # Validate SambaPOS config - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - if not tracked_categories: - raise HTTPException(status_code=400, detail="No SambaPOS categories configured") - - excluded_items = [] - if settings.sambapos_excluded_items: - excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] - - # Fetch sales data from SambaPOS - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - sales = await client.get_sales_breakdown( - from_date, to_date, tracked_categories, - excluded_categories=excluded_items if excluded_items else None - ) - except Exception as e: - raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") - - # Load all dish recipes with SambaPOS mapping - recipe_result = await db.execute( - select(Recipe) - .options(selectinload(Recipe.menu_section)) - .where( - Recipe.kitchen_id == current_user.kitchen_id, - Recipe.recipe_type == "dish", - Recipe.kds_menu_item_name.isnot(None), - Recipe.kds_menu_item_name != "", - Recipe.is_archived == False, - ) - ) - recipes = recipe_result.scalars().all() - - # Build lookup: (menu_item_name, portion_name_or_none) -> recipe - recipe_lookup: dict[tuple[str, str | None], object] = {} - for r in recipes: - if r.sambapos_portion_name: - recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r - else: - recipe_lookup[(r.kds_menu_item_name, None)] = r - - # Get latest cost snapshots for all mapped recipes - recipe_ids = [r.id for r in recipes] - cost_lookup: dict[int, Decimal] = {} - if recipe_ids: - # Get latest snapshot per recipe using a subquery - from sqlalchemy import and_ - for rid in recipe_ids: - snap_result = await db.execute( - select(RecipeCostSnapshot) - .where(RecipeCostSnapshot.recipe_id == rid) - .order_by(RecipeCostSnapshot.snapshot_date.desc()) - .limit(1) - ) - snap = snap_result.scalar_one_or_none() - if snap and snap.cost_per_portion: - cost_lookup[rid] = snap.cost_per_portion - - # Match sales to recipes and calculate GP - VAT_RATE = Decimal("1.20") - mapped_items: list[SalesGPItem] = [] - unmapped_items: list[SalesGPItem] = [] - - for sale in sales: - revenue_gross = Decimal(str(sale["total_revenue_gross"])) - revenue_net = (revenue_gross / VAT_RATE).quantize(Decimal("0.01")) - qty = sale["total_qty"] - dbb_qty = sale.get("package_qty", 0) - menu_name = sale["menu_item_name"] - portion = sale["portion_name"] - - # Try exact match first, then name-only match - matched_recipe = recipe_lookup.get((menu_name, portion)) - if not matched_recipe and portion != "Normal": - matched_recipe = recipe_lookup.get((menu_name, None)) - if not matched_recipe and portion == "Normal": - matched_recipe = recipe_lookup.get((menu_name, None)) - - if matched_recipe: - cpp = cost_lookup.get(matched_recipe.id) - total_cost = (cpp * qty).quantize(Decimal("0.01")) if cpp else None - gp_pct = None - if total_cost is not None and revenue_net > 0: - gp_pct = ((revenue_net - total_cost) / revenue_net * 100).quantize(Decimal("0.1")) - - mapped_items.append(SalesGPItem( - menu_item_name=menu_name, - portion_name=portion, - category=sale["category"], - total_qty=qty, - total_revenue_net=revenue_net, - dbb_qty=dbb_qty, - recipe_id=matched_recipe.id, - recipe_name=matched_recipe.name, - dish_course=matched_recipe.menu_section.name if matched_recipe.menu_section else "Uncategorised", - cost_per_portion=cpp, - total_cost=total_cost, - item_gp_percent=gp_pct, - )) - else: - unmapped_items.append(SalesGPItem( - menu_item_name=menu_name, - portion_name=portion, - category=sale["category"], - total_qty=qty, - total_revenue_net=revenue_net, - dbb_qty=dbb_qty, - )) - - # Group mapped items by SambaPOS Kitchen Course category (matches tracked_categories order) - course_groups: dict[str, list[SalesGPItem]] = {} - for item in mapped_items: - course = item.category or "Uncategorised" - if course not in course_groups: - course_groups[course] = [] - course_groups[course].append(item) - - # Sort courses using tracked_categories order from settings, unrecognised courses at the end - category_order = {cat: idx for idx, cat in enumerate(tracked_categories)} - sorted_courses = sorted(course_groups.items(), key=lambda x: (category_order.get(x[0], 999), x[0])) - - courses = [] - for course_name, items in sorted_courses: - c_revenue = sum(i.total_revenue_net for i in items) - c_cost = sum(i.total_cost for i in items if i.total_cost) - c_gp = ((c_revenue - c_cost) / c_revenue * 100).quantize(Decimal("0.1")) if c_revenue > 0 else None - # Sort items by revenue descending - items.sort(key=lambda x: x.total_revenue_net, reverse=True) - courses.append(SalesGPCourseGroup( - course_name=course_name, - items=items, - course_revenue=c_revenue, - course_cost=c_cost, - course_gp_percent=c_gp, - )) - - # Summary totals - mapped_revenue = sum(i.total_revenue_net for i in mapped_items) - mapped_cost = sum(i.total_cost for i in mapped_items if i.total_cost) - unmapped_revenue = sum(i.total_revenue_net for i in unmapped_items) - total_revenue = mapped_revenue + unmapped_revenue - mapped_gp = ((mapped_revenue - mapped_cost) / mapped_revenue * 100).quantize(Decimal("0.1")) if mapped_revenue > 0 else None - coverage_pct = (mapped_revenue / total_revenue * 100).quantize(Decimal("0.1")) if total_revenue > 0 else Decimal("0") - - # Sort unmapped by revenue descending - unmapped_items.sort(key=lambda x: x.total_revenue_net, reverse=True) - - return SalesGPResponse( - from_date=from_date, - to_date=to_date, - courses=courses, - unmapped_items=unmapped_items, - mapped_revenue_net=mapped_revenue, - mapped_total_cost=mapped_cost, - mapped_gp_percent=mapped_gp, - total_all_revenue_net=total_revenue, - unmapped_revenue_net=unmapped_revenue, - mapped_revenue_percent=coverage_pct, - mapped_item_count=len(mapped_items), - unmapped_item_count=len(unmapped_items), - ) - - -# ══════════════════════════════════════════════════════════════════════════════ -# THEORETICAL VS ACTUAL USAGE (REVERSE COSTING) -# ══════════════════════════════════════════════════════════════════════════════ - -class UsageVarianceItem(BaseModel): - ingredient_id: int - ingredient_name: str - category: str | None = None - standard_unit: str - # Theoretical (from sales × recipes) - theoretical_qty: float - theoretical_value: float - dishes_using: int - # Actual (from invoices) - actual_qty: float | None = None - actual_value: float | None = None - invoice_count: int = 0 - # Variance - variance_qty: float | None = None - variance_pct: float | None = None - variance_value: float | None = None - -class UnmappedSaleItem(BaseModel): - menu_item_name: str - portion_name: str - total_qty: int - category: str | None = None - -class UsageVarianceResponse(BaseModel): - from_date: date - to_date: date - items: list[UsageVarianceItem] - total_theoretical_value: float - total_actual_value: float - total_variance_value: float - mapped_dish_count: int - unmapped_dish_count: int - ingredients_with_purchases: int - ingredients_without_purchases: int - unmapped_sales: list[UnmappedSaleItem] - - -def _expand_recipe_ingredients( - recipe, - scale: float, - theoretical: dict, - dishes_using: dict, - recipe_name: str, - visited: set | None = None, - depth: int = 0, -): - """ - Recursively expand a recipe's ingredients into the theoretical usage dict. - Max depth of 3 to avoid lazy-load errors on deeply nested sub-recipes. - """ - from api.ingredients import convert_to_standard, UNIT_CONVERSIONS - - if depth > 3: - return - if visited is None: - visited = set() - if recipe.id in visited: - return - visited.add(recipe.id) - - # Direct ingredients — guard against lazy load - try: - ingredients = recipe.ingredients - except Exception: - return - - for ri in ingredients: - ing = ri.ingredient - if not ing or ing.is_archived: - continue - - qty_in_unit = float(ri.quantity) * scale - from_unit = (ri.unit or ing.standard_unit).lower().strip() - to_unit = ing.standard_unit.lower().strip() - - if from_unit == to_unit: - qty_std = qty_in_unit - else: - converted = convert_to_standard(Decimal(str(qty_in_unit)), from_unit, to_unit) - qty_std = float(converted) if converted is not None else qty_in_unit - - yield_pct = float(ri.yield_percent or 100) - if yield_pct > 0 and yield_pct < 100: - qty_raw = qty_std / (yield_pct / 100.0) - else: - qty_raw = qty_std - - theoretical[ing.id] = theoretical.get(ing.id, 0.0) + qty_raw - if ing.id not in dishes_using: - dishes_using[ing.id] = set() - dishes_using[ing.id].add(recipe_name) - - # Sub-recipes — guard against lazy load at deeper levels - try: - sub_recipes = recipe.sub_recipes - except Exception: - return - - for sr in sub_recipes: - child = sr.child_recipe - if not child: - continue - - child_output = child.batch_portions or 1 - child_output_unit = "portion" - if child.batch_output_type == "bulk" and child.batch_yield_qty: - child_output = float(child.batch_yield_qty) - child_output_unit = (child.batch_yield_unit or "portion").lower().strip() - - # Convert portions_needed to child's output unit if units differ - needed = float(sr.portions_needed) - needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip() - if needed_unit != child_output_unit and child.batch_output_type == "bulk": - converted = convert_to_standard(Decimal(str(needed)), needed_unit, child_output_unit) - if converted is not None: - needed = float(converted) - - sub_scale = needed * scale / child_output - _expand_recipe_ingredients( - child, sub_scale, theoretical, dishes_using, recipe_name, visited.copy(), depth + 1 - ) - - -@router.get("/usage-variance", response_model=UsageVarianceResponse) -async def get_usage_variance( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Theoretical vs Actual Usage report (reverse costing). - - Compares ingredient usage implied by SambaPOS sales + recipes - against actual purchases from Flash invoices for the same period. - """ - from models.settings import KitchenSettings - from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe - from models.ingredient import Ingredient, IngredientSource, IngredientCategory - from models.line_item import LineItem - from services.sambapos_api import SambaPOSClient - from api.ingredients import convert_to_standard, UNIT_CONVERSIONS - from sqlalchemy import distinct, and_, or_ - - if from_date > to_date: - raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") - - kitchen_id = current_user.kitchen_id - - # ── Settings + SambaPOS config ── - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id) - ) - settings = result.scalar_one_or_none() - if not settings: - raise HTTPException(status_code=400, detail="Settings not configured") - - if not all([ - settings.sambapos_db_host, settings.sambapos_db_name, - settings.sambapos_db_username, settings.sambapos_db_password, - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - tracked_categories = [ - c.strip() for c in (settings.sambapos_tracked_categories or "").split(",") if c.strip() - ] - if not tracked_categories: - raise HTTPException(status_code=400, detail="No SambaPOS categories configured") - - excluded_items = [ - i.strip() for i in (settings.sambapos_excluded_items or "").split("|") if i.strip() - ] - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password, - ) - - # ── Step 1: Get sales + match to recipes ── - try: - sales = await client.get_sales_breakdown( - from_date, to_date, tracked_categories, - excluded_categories=excluded_items if excluded_items else None, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") - - # Load recipes with full ingredient chain + sub-recipes (2 levels deep) - recipe_result = await db.execute( - select(Recipe) - .options( - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) - .selectinload(Ingredient.sources), - selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) - .selectinload(Ingredient.category), - selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) - .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) - .selectinload(Ingredient.sources), - selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) - .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) - .selectinload(Ingredient.category), - selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) - .selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) - .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) - .selectinload(Ingredient.sources), - ) - .where( - Recipe.kitchen_id == kitchen_id, - Recipe.recipe_type == "dish", - Recipe.kds_menu_item_name.isnot(None), - Recipe.kds_menu_item_name != "", - Recipe.is_archived == False, - ) - ) - recipes = recipe_result.scalars().all() - - # Build lookup - recipe_lookup: dict[tuple[str, str | None], object] = {} - for r in recipes: - if r.sambapos_portion_name: - recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r - else: - recipe_lookup[(r.kds_menu_item_name, None)] = r - - # ── Step 2: Explode recipes into theoretical ingredient usage ── - theoretical: dict[int, float] = {} # ingredient_id -> qty in std unit - dishes_using: dict[int, set] = {} # ingredient_id -> set of dish names - ingredient_cache: dict[int, object] = {} # ingredient_id -> Ingredient obj - unmapped_sales: list[UnmappedSaleItem] = [] - mapped_dish_count = 0 - unmapped_dish_count = 0 - - for sale in sales: - menu_name = sale["menu_item_name"] - portion = sale["portion_name"] - qty = sale["total_qty"] - - matched_recipe = recipe_lookup.get((menu_name, portion)) - if not matched_recipe and portion != "Normal": - matched_recipe = recipe_lookup.get((menu_name, None)) - if not matched_recipe and portion == "Normal": - matched_recipe = recipe_lookup.get((menu_name, None)) - - if not matched_recipe: - unmapped_dish_count += 1 - unmapped_sales.append(UnmappedSaleItem( - menu_item_name=menu_name, - portion_name=portion, - total_qty=qty, - category=sale.get("category"), - )) - continue - - mapped_dish_count += 1 - batch_portions = matched_recipe.batch_portions or 1 - scale = qty / batch_portions - - _expand_recipe_ingredients( - matched_recipe, scale, theoretical, dishes_using, matched_recipe.name - ) - - # Cache ingredient objects for later use - for ri in matched_recipe.ingredients: - if ri.ingredient and ri.ingredient.id not in ingredient_cache: - ingredient_cache[ri.ingredient.id] = ri.ingredient - for sr in matched_recipe.sub_recipes: - if sr.child_recipe: - for ri in sr.child_recipe.ingredients: - if ri.ingredient and ri.ingredient.id not in ingredient_cache: - ingredient_cache[ri.ingredient.id] = ri.ingredient - - # ── Step 3: Get actual purchases ── - # Value aggregation (reliable — always works) - purchase_value_query = ( - select( - LineItem.ingredient_id, - func.sum( - case( - (Invoice.document_type == "credit_note", -LineItem.amount), - else_=LineItem.amount, - ) - ).label("total_value"), - func.count(distinct(Invoice.id)).label("invoice_count"), - ) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.status == InvoiceStatus.CONFIRMED, - Invoice.invoice_date.between(from_date, to_date), - LineItem.ingredient_id.isnot(None), - LineItem.amount.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)), - ) - .group_by(LineItem.ingredient_id) - ) - pv_result = await db.execute(purchase_value_query) - purchase_values: dict[int, dict] = {} - for row in pv_result.all(): - purchase_values[row.ingredient_id] = { - "total_value": float(row.total_value or 0), - "invoice_count": row.invoice_count, - } - - # Quantity aggregation (per line item — need pack conversion) - # Fetch raw line items for ingredients we care about - all_ingredient_ids = set(theoretical.keys()) | set(purchase_values.keys()) - purchase_qtys: dict[int, float] = {} - - if all_ingredient_ids: - li_query = ( - select(LineItem, Invoice.document_type) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where( - Invoice.kitchen_id == kitchen_id, - Invoice.status == InvoiceStatus.CONFIRMED, - Invoice.invoice_date.between(from_date, to_date), - LineItem.ingredient_id.in_(all_ingredient_ids), - LineItem.quantity.isnot(None), - or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)), - ) - ) - li_result = await db.execute(li_query) - - # We need ingredient standard_units — load any missing from DB - missing_ids = all_ingredient_ids - set(ingredient_cache.keys()) - if missing_ids: - ing_result = await db.execute( - select(Ingredient) - .options(selectinload(Ingredient.category), selectinload(Ingredient.sources)) - .where(Ingredient.id.in_(missing_ids)) - ) - for ing in ing_result.scalars().all(): - ingredient_cache[ing.id] = ing - - for row in li_result.all(): - li = row[0] # LineItem - doc_type = row[1] # document_type - ing_id = li.ingredient_id - ing = ingredient_cache.get(ing_id) - if not ing: - continue - - std_unit = ing.standard_unit.lower().strip() - qty = float(li.quantity) - std_qty = None - - # Try pack conversion: quantity × pack_quantity × unit_size → convert - if li.pack_quantity and li.unit_size and li.unit_size_type: - total_source = qty * li.pack_quantity * float(li.unit_size) - converted = convert_to_standard( - Decimal(str(total_source)), li.unit_size_type, std_unit - ) - if converted is not None: - std_qty = float(converted) - # Fallback: if line item unit matches a known unit - elif li.unit and li.unit.lower().strip() in UNIT_CONVERSIONS: - converted = convert_to_standard( - Decimal(str(qty)), li.unit.lower().strip(), std_unit - ) - if converted is not None: - std_qty = float(converted) - - if std_qty is not None: - if doc_type == "credit_note": - std_qty = -std_qty - purchase_qtys[ing_id] = purchase_qtys.get(ing_id, 0.0) + std_qty - - # ── Step 4: Build comparison ── - items: list[UsageVarianceItem] = [] - total_theoretical_value = 0.0 - total_actual_value = 0.0 - ingredients_with_purchases = 0 - ingredients_without_purchases = 0 - - for ing_id in all_ingredient_ids: - ing = ingredient_cache.get(ing_id) - if not ing: - continue - - cat_name = ing.category.name if ing.category else None - std_unit = ing.standard_unit - - # Theoretical - theo_qty = theoretical.get(ing_id, 0.0) - dish_count = len(dishes_using.get(ing_id, set())) - - # Get best price per standard unit for theoretical value calc - price_per_std = None - if ing.sources: - # Use most recent source price - priced_sources = [ - s for s in ing.sources if s.price_per_std_unit and s.price_per_std_unit > 0 - ] - if priced_sources: - priced_sources.sort(key=lambda s: s.latest_invoice_date or date.min, reverse=True) - price_per_std = float(priced_sources[0].price_per_std_unit) - if price_per_std is None and ing.manual_price: - price_per_std = float(ing.manual_price) - - theo_value = theo_qty * price_per_std if price_per_std else 0.0 - total_theoretical_value += theo_value - - # Actual - pv = purchase_values.get(ing_id) - actual_value = pv["total_value"] if pv else None - invoice_count = pv["invoice_count"] if pv else 0 - actual_qty = purchase_qtys.get(ing_id) - - if actual_value is not None: - total_actual_value += actual_value - ingredients_with_purchases += 1 - elif theo_qty > 0: - ingredients_without_purchases += 1 - - # Variance - variance_qty = None - variance_pct = None - variance_value = None - - if actual_qty is not None and theo_qty > 0: - variance_qty = actual_qty - theo_qty - variance_pct = (variance_qty / theo_qty) * 100.0 - - if actual_value is not None and theo_value > 0: - variance_value = actual_value - theo_value - elif actual_value is not None and theo_qty == 0: - # Purchased but no theoretical usage (not in any recipe) - variance_value = actual_value - - items.append(UsageVarianceItem( - ingredient_id=ing_id, - ingredient_name=ing.name, - category=cat_name, - standard_unit=std_unit, - theoretical_qty=round(theo_qty, 2), - theoretical_value=round(theo_value, 2), - dishes_using=dish_count, - actual_qty=round(actual_qty, 2) if actual_qty is not None else None, - actual_value=round(actual_value, 2) if actual_value is not None else None, - invoice_count=invoice_count, - variance_qty=round(variance_qty, 2) if variance_qty is not None else None, - variance_pct=round(variance_pct, 1) if variance_pct is not None else None, - variance_value=round(variance_value, 2) if variance_value is not None else None, - )) - - # Sort by absolute variance value descending (biggest £ problems first) - items.sort(key=lambda x: abs(x.variance_value or 0), reverse=True) - - total_variance = total_actual_value - total_theoretical_value - - return UsageVarianceResponse( - from_date=from_date, - to_date=to_date, - items=items, - total_theoretical_value=round(total_theoretical_value, 2), - total_actual_value=round(total_actual_value, 2), - total_variance_value=round(total_variance, 2), - mapped_dish_count=mapped_dish_count, - unmapped_dish_count=unmapped_dish_count, - ingredients_with_purchases=ingredients_with_purchases, - ingredients_without_purchases=ingredients_without_purchases, - unmapped_sales=unmapped_sales, - ) diff --git a/backend/api/residents_table_chart.py b/backend/api/residents_table_chart.py deleted file mode 100644 index feceef4..0000000 --- a/backend/api/residents_table_chart.py +++ /dev/null @@ -1,422 +0,0 @@ -""" -Residents Table Chart API - -Gantt-style visualization showing hotel bookings with restaurant table indicators. -""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, or_ -from datetime import date, timedelta -from typing import Optional -from pydantic import BaseModel - -from auth import get_current_user, require_cap -from database import get_db -from models.user import User -from models.newbook import NewbookDailyOccupancy -from models.resos import ResosBooking - -router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"]) - - -class RestaurantBookingDetail(BaseModel): - has_booking: bool - time: Optional[str] = None - people: Optional[int] = None - table_name: Optional[str] = None - opening_hour_name: Optional[str] = None - is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking) - - -class BookingSegment(BaseModel): - booking_id: str | None - bookings_group_id: Optional[str] = None - check_in: str - check_out: str - nights: list[str] - is_dbb: Optional[bool] = None - is_package: Optional[bool] = None - restaurant_bookings: dict[str, RestaurantBookingDetail] - - -class RoomRow(BaseModel): - room_number: str | None - bookings: list[BookingSegment] # Multiple bookings in the same room - - -class ResidentsTableChartResponse(BaseModel): - date_range: dict - rooms: list[RoomRow] # Changed from 'bookings' to 'rooms' - summary: dict - metrics: Optional[dict] = None # Aggregated metrics for different time periods - - -@router.get("") -async def get_residents_table_chart( - start_date: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> ResidentsTableChartResponse: - """ - Get Gantt-style chart data showing hotel bookings with restaurant table indicators. - - Args: - start_date: First day of 7-day period (defaults to today) - - Returns: - Chart data with hotel stays and restaurant booking indicators - """ - import logging - logger = logging.getLogger(__name__) - - if start_date is None: - start_date = date.today() - - logger.info(f"ResidentsTableChart API called with start_date={start_date}") - - end_date = start_date + timedelta(days=6) # 7-day period - date_range = { - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)] - } - - # Fetch Newbook occupancy data for 7-day period - result = await db.execute( - select(NewbookDailyOccupancy).where( - and_( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= start_date, - NewbookDailyOccupancy.date <= end_date, - NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown - ) - ).order_by(NewbookDailyOccupancy.date) - ) - occupancy_records = result.scalars().all() - logger.info(f"Found {len(occupancy_records)} occupancy records") - - # Group by room number only (one row per room in Gantt chart) - # Key: room_number, Value: dict of bookings for that room - rooms_dict = {} - for record in occupancy_records: - # Parse JSONB array - each element is a room object for this date - rooms = record.rooms_breakdown or [] - - for room in rooms: - room_number = room.get("room_number") - booking_id = room.get("booking_id") - - if room_number not in rooms_dict: - rooms_dict[room_number] = {} - - # Track each booking within this room - if booking_id not in rooms_dict[room_number]: - rooms_dict[room_number][booking_id] = { - 'booking_id': booking_id, - 'bookings_group_id': room.get("bookings_group_id"), - 'nights': [], - 'is_dbb': room.get("is_dbb", False), - 'is_package': room.get("is_package", False) - } - - rooms_dict[room_number][booking_id]['nights'].append(record.date) - - # Log rooms with multiple bookings to diagnose stacking issue - for room_number, bookings in rooms_dict.items(): - if len(bookings) > 1: - logger.warning(f"Room {room_number} has {len(bookings)} different bookings:") - for booking_id, booking_data in bookings.items(): - nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']])) - logger.warning(f" - Booking {booking_id}: nights={nights_str}") - - # Convert to list - one entry per room with all its bookings - hotel_stays = [] - for room_number, bookings in rooms_dict.items(): - # Collect all bookings for this room - room_bookings = [] - all_nights = [] - - for booking_data in bookings.values(): - nights = sorted(booking_data['nights']) - if nights: - all_nights.extend(nights) - check_in = nights[0] - check_out = nights[-1] + timedelta(days=1) - - room_bookings.append({ - 'booking_id': booking_data['booking_id'], - 'bookings_group_id': booking_data.get('bookings_group_id'), - 'check_in': check_in.isoformat(), - 'check_out': check_out.isoformat(), - 'nights': [n.isoformat() for n in nights], - 'is_dbb': booking_data['is_dbb'], - 'is_package': booking_data['is_package'] - }) - - # Create one entry per room with all bookings - if room_bookings: - all_nights_sorted = sorted(set(all_nights)) - hotel_stays.append({ - 'room_number': room_number, - 'bookings': room_bookings, # Array of all bookings in this room - 'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room - }) - - logger.info(f"Built {len(hotel_stays)} room entries") - - # Fetch Resos bookings for hotel guests in this period - result = await db.execute( - select(ResosBooking).where( - and_( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date >= start_date, - ResosBooking.booking_date <= end_date, - ResosBooking.is_hotel_guest == True, - ResosBooking.hotel_booking_number.isnot(None) - ) - ) - ) - resos_bookings = result.scalars().all() - - # Build lookup: booking_id -> {date -> resos_booking} - # Also handle group bookings via exclude_flag field (format: "#32990,#32991") - resos_lookup = {} - import re - - for resos_booking in resos_bookings: - booking_id = resos_booking.hotel_booking_number - booking_date = resos_booking.booking_date.isoformat() - - if booking_id not in resos_lookup: - resos_lookup[booking_id] = {} - - # Direct match for the lead/primary booking - resos_lookup[booking_id][booking_date] = { - 'has_booking': True, - 'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None, - 'people': resos_booking.people, - 'table_name': resos_booking.table_name, - 'opening_hour_name': resos_booking.opening_hour_name, - 'is_group_match': False # Direct match, not a group member - } - - # Parse exclude_flag for group bookings (format: "#32990,#32991") - if resos_booking.exclude_flag: - # Extract all booking numbers from the exclude_flag field - group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag) - - for group_id in group_booking_ids: - # Skip the lead booking itself (already added above) - if group_id == booking_id: - continue - - # Add group member with is_group_match=True - if group_id not in resos_lookup: - resos_lookup[group_id] = {} - - # Only add if not already present (don't overwrite direct matches) - if booking_date not in resos_lookup[group_id]: - resos_lookup[group_id][booking_date] = { - 'has_booking': True, - 'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None, - 'people': resos_booking.people, - 'table_name': resos_booking.table_name, - 'opening_hour_name': resos_booking.opening_hour_name, - 'is_group_match': True # Matched via group, not direct - } - - logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)") - - # Combine rooms with restaurant bookings - room_rows = [] - total_room_nights = 0 - nights_with_restaurant = 0 - - try: - for room_data in hotel_stays: - booking_segments = [] - - # Process each booking within this room - for booking_data in room_data['bookings']: - # Build restaurant bookings dict for each night in the 7-day period - restaurant_bookings = {} - - for date_str in date_range['dates']: - # Check if this date is within this specific booking's nights - if date_str in booking_data['nights']: - total_room_nights += 1 - - # Check if there's a restaurant booking for this date - resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str) - - if resos_data: - restaurant_bookings[date_str] = resos_data - nights_with_restaurant += 1 - else: - restaurant_bookings[date_str] = {'has_booking': False} - else: - # Not staying this night - restaurant_bookings[date_str] = {'has_booking': False} - - # Create booking segment with restaurant data - booking_segments.append(BookingSegment( - booking_id=booking_data['booking_id'], - bookings_group_id=booking_data.get('bookings_group_id'), - check_in=booking_data['check_in'], - check_out=booking_data['check_out'], - nights=booking_data['nights'], - is_dbb=booking_data['is_dbb'], - is_package=booking_data['is_package'], - restaurant_bookings=restaurant_bookings - )) - - # Create room row with all its bookings - room_rows.append(RoomRow( - room_number=room_data['room_number'], - bookings=booking_segments - )) - except Exception as e: - logger.error(f"Error building room_rows: {e}", exc_info=True) - raise - - logger.info(f"Built {len(room_rows)} room rows") - - # Sort rooms by room number (natural sort for numeric rooms) - def natural_sort_key(room: RoomRow): - """Natural sort key for room numbers (handles both numeric and alphanumeric)""" - if not room.room_number: - return (float('inf'), '') # Put None/empty at end - - # Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12) - import re - numbers = re.findall(r'\d+', room.room_number) - if numbers: - return (int(numbers[0]), room.room_number) - return (float('inf'), room.room_number) - - room_rows.sort(key=natural_sort_key) - - # Calculate summary - coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0 - - # Count total bookings across all rooms - total_bookings = sum(len(room.bookings) for room in room_rows) - - summary = { - 'total_rooms': len(room_rows), - 'total_bookings': total_bookings, - 'total_room_nights': total_room_nights, - 'nights_with_restaurant': nights_with_restaurant, - 'coverage_percentage': round(coverage_pct, 1) - } - - # Calculate aggregated metrics for different time periods - def get_week_start(d: date) -> date: - """Get Monday of the week containing date d""" - return d - timedelta(days=d.weekday()) - - async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict: - """Calculate metrics for a specific date range""" - query = select(NewbookDailyOccupancy).where( - and_( - NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, - NewbookDailyOccupancy.date >= period_start, - NewbookDailyOccupancy.date <= period_end, - NewbookDailyOccupancy.rooms_breakdown.isnot(None) - ) - ) - - # Filter by forecast status if specified - if is_forecast is not None: - query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast) - - result = await db.execute(query.order_by(NewbookDailyOccupancy.date)) - records = result.scalars().all() - - # Count metrics - total_room_nights_period = 0 - unique_bookings = set() - nights_with_rest = 0 - - for record in records: - rooms = record.rooms_breakdown or [] - for room in rooms: - booking_id = room.get("booking_id") - if booking_id: - unique_bookings.add(booking_id) - total_room_nights_period += 1 - - # Check if has restaurant booking for this date - date_str = record.date.isoformat() - resos_data = resos_lookup.get(booking_id, {}).get(date_str) - if resos_data: - nights_with_rest += 1 - - coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0 - - # Calculate average occupancy - total_available = 0 - total_occupied = 0 - for record in records: - if record.total_rooms and record.occupied_rooms: - total_available += record.total_rooms - total_occupied += record.occupied_rooms - - avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0 - - return { - 'total_bookings': len(unique_bookings), - 'total_room_nights': total_room_nights_period, - 'nights_with_restaurant': nights_with_rest, - 'coverage_percentage': round(coverage_pct_period, 1), - 'avg_occupancy_percentage': round(avg_occupancy, 1) - } - - today = date.today() - - # This week (Monday to Sunday) - this_week_start = get_week_start(today) - this_week_end = this_week_start + timedelta(days=6) - - # Last week (previous Monday to Sunday) - last_week_start = this_week_start - timedelta(days=7) - last_week_end = last_week_start + timedelta(days=6) - - # Last 30 days rolling (from yesterday) - yesterday = today - timedelta(days=1) - rolling_30_start = yesterday - timedelta(days=29) - rolling_30_end = yesterday - - # Calculate metrics for each period - always return metrics with default values - default_metrics = { - 'total_bookings': 0, - 'total_room_nights': 0, - 'nights_with_restaurant': 0, - 'coverage_percentage': 0.0, - 'avg_occupancy_percentage': 0.0 - } - - try: - metrics = { - 'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False), - 'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True), - 'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False), - 'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False), - } - logger.info(f"Calculated metrics: {metrics}") - except Exception as e: - logger.error(f"Error calculating metrics: {e}", exc_info=True) - # Return default metrics structure instead of None - metrics = { - 'this_week_actual': default_metrics.copy(), - 'this_week_forecast': default_metrics.copy(), - 'last_week_actual': default_metrics.copy(), - 'last_30_days_rolling': default_metrics.copy(), - } - - return ResidentsTableChartResponse( - date_range=date_range, - rooms=room_rows, - summary=summary, - metrics=metrics - ) diff --git a/backend/api/resos.py b/backend/api/resos.py deleted file mode 100644 index 9d9a9b2..0000000 --- a/backend/api/resos.py +++ /dev/null @@ -1,809 +0,0 @@ -""" -Resos API Endpoints - -Handles Resos configuration, sync operations, and booking data retrieval. -""" -import logging -from datetime import date, datetime, timedelta -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, func, Date, case, or_ -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog -from auth import get_current_user, require_cap -from services.resos_sync import ResosSyncService -from services.resos_api import ResosAPIClient, ResosAPIError - -router = APIRouter() - - -# ============ Pydantic Schemas ============ - -class ResosSettingsResponse(BaseModel): - resos_api_key_set: bool # Masked - resos_last_sync: datetime | None - resos_auto_sync_enabled: bool - resos_upcoming_sync_enabled: bool - resos_upcoming_sync_interval: int - resos_last_upcoming_sync: datetime | None - resos_large_group_threshold: int - resos_note_keywords: str | None - resos_allergy_keywords: str | None - resos_custom_field_mapping: dict | None - resos_opening_hours_mapping: list | None - resos_restaurant_table_entities: str | None - resos_enable_manual_breakfast: bool - resos_manual_breakfast_periods: list | None - resos_flag_icon_mapping: dict | None - resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other - sambapos_food_gl_codes: str | None # Phase 8.1 - sambapos_beverage_gl_codes: str | None # Phase 8.1 - - class Config: - from_attributes = True - - -class ResosSettingsUpdate(BaseModel): - resos_api_key: str | None = None - resos_auto_sync_enabled: bool | None = None - resos_upcoming_sync_enabled: bool | None = None - resos_upcoming_sync_interval: int | None = None - resos_large_group_threshold: int | None = None - resos_note_keywords: str | None = None - resos_allergy_keywords: str | None = None - resos_custom_field_mapping: dict | None = None - resos_opening_hours_mapping: list | None = None - resos_restaurant_table_entities: str | None = None - resos_enable_manual_breakfast: bool | None = None - resos_manual_breakfast_periods: list | None = None - resos_flag_icon_mapping: dict | None = None - resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter - sambapos_food_gl_codes: str | None = None # Phase 8.1 - sambapos_beverage_gl_codes: str | None = None # Phase 8.1 - - -class DailyStatsResponse(BaseModel): - date: str - total_bookings: int - total_covers: int - service_breakdown: list[dict] - flagged_booking_count: int - unique_flag_types: list[str] | None - is_forecast: bool - - class Config: - from_attributes = True - - -class BookingResponse(BaseModel): - id: int - resos_booking_id: str - booking_date: str - booking_time: str - people: int - status: str - seating_area: str | None - hotel_booking_number: str | None - is_hotel_guest: bool | None - is_dbb: bool | None - is_package: bool | None - allergies: str | None - notes: str | None - opening_hour_name: str | None - is_flagged: bool - flag_reasons: str | None - - class Config: - from_attributes = True - - -class DashboardCoversResponse(BaseModel): - date: str - total_bookings: int - total_covers: int - service_breakdown: list[dict] - has_flagged_bookings: bool - unique_flag_types: list[str] - - class Config: - from_attributes = True - - -# ============ Settings Endpoints ============ - -@router.get("/settings") -async def get_resos_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> ResosSettingsResponse: - """Get Resos settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - - logger.info(f"[Resos GET] Returning upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") - - return ResosSettingsResponse( - resos_api_key_set=bool(settings.resos_api_key), - resos_last_sync=settings.resos_last_sync, - resos_auto_sync_enabled=settings.resos_auto_sync_enabled or False, - resos_upcoming_sync_enabled=settings.resos_upcoming_sync_enabled or False, - resos_upcoming_sync_interval=settings.resos_upcoming_sync_interval or 15, - resos_last_upcoming_sync=settings.resos_last_upcoming_sync, - resos_large_group_threshold=settings.resos_large_group_threshold or 8, - resos_note_keywords=settings.resos_note_keywords, - resos_allergy_keywords=settings.resos_allergy_keywords, - resos_custom_field_mapping=settings.resos_custom_field_mapping, - resos_opening_hours_mapping=settings.resos_opening_hours_mapping, - resos_restaurant_table_entities=settings.resos_restaurant_table_entities, - resos_enable_manual_breakfast=settings.resos_enable_manual_breakfast or False, - resos_manual_breakfast_periods=settings.resos_manual_breakfast_periods, - resos_flag_icon_mapping=settings.resos_flag_icon_mapping, - resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter, - sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1 - sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1 - ) - - -@router.patch("/settings") -async def update_resos_settings( - update: ResosSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update Resos settings""" - logger.info(f"[Resos PATCH] Received update: {update.model_dump(exclude_unset=True)}") - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - - logger.info(f"[Resos PATCH] Before update - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") - - if update.resos_api_key is not None: - settings.resos_api_key = update.resos_api_key - if update.resos_auto_sync_enabled is not None: - settings.resos_auto_sync_enabled = update.resos_auto_sync_enabled - if update.resos_upcoming_sync_enabled is not None: - logger.info(f"[Resos PATCH] Setting upcoming_sync_enabled to {update.resos_upcoming_sync_enabled}") - settings.resos_upcoming_sync_enabled = update.resos_upcoming_sync_enabled - if update.resos_upcoming_sync_interval is not None: - settings.resos_upcoming_sync_interval = update.resos_upcoming_sync_interval - if update.resos_large_group_threshold is not None: - settings.resos_large_group_threshold = update.resos_large_group_threshold - if update.resos_note_keywords is not None: - settings.resos_note_keywords = update.resos_note_keywords - if update.resos_allergy_keywords is not None: - settings.resos_allergy_keywords = update.resos_allergy_keywords - if update.resos_custom_field_mapping is not None: - settings.resos_custom_field_mapping = update.resos_custom_field_mapping - if update.resos_opening_hours_mapping is not None: - settings.resos_opening_hours_mapping = update.resos_opening_hours_mapping - if update.resos_restaurant_table_entities is not None: - settings.resos_restaurant_table_entities = update.resos_restaurant_table_entities - if update.resos_enable_manual_breakfast is not None: - settings.resos_enable_manual_breakfast = update.resos_enable_manual_breakfast - if update.resos_manual_breakfast_periods is not None: - settings.resos_manual_breakfast_periods = update.resos_manual_breakfast_periods - if update.resos_flag_icon_mapping is not None: - settings.resos_flag_icon_mapping = update.resos_flag_icon_mapping - if update.resos_arrival_widget_service_filter is not None: - settings.resos_arrival_widget_service_filter = update.resos_arrival_widget_service_filter - # Phase 8.1: GL codes for food/beverage spend split - if update.sambapos_food_gl_codes is not None: - settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes - if update.sambapos_beverage_gl_codes is not None: - settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes - - await db.commit() - logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") - return {"message": "Settings updated successfully"} - - -@router.post("/test-connection") -async def test_resos_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test Resos API connection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - - if not settings.resos_api_key: - raise HTTPException(status_code=400, detail="Resos API key not configured") - - async with ResosAPIClient(settings.resos_api_key) as client: - success = await client.test_connection() - - if not success: - raise HTTPException(status_code=400, detail="Connection failed") - - return {"message": "Connection successful"} - - -@router.get("/debug-upcoming-sync") -async def debug_upcoming_sync( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Debug endpoint to check upcoming sync settings directly""" - from sqlalchemy import text - - # Get value via ORM - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - orm_value = settings.resos_upcoming_sync_enabled - - # Get value via raw SQL - raw_result = await db.execute( - text("SELECT resos_upcoming_sync_enabled, resos_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"), - {"kid": current_user.kitchen_id} - ) - raw_row = raw_result.fetchone() - - return { - "orm_value": orm_value, - "raw_db_enabled": raw_row[0] if raw_row else None, - "raw_db_interval": raw_row[1] if raw_row else None, - "column_exists": raw_row is not None - } - - -@router.get("/custom-fields") -async def fetch_custom_fields( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch custom field definitions from Resos API (GET request only)""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - - if not settings.resos_api_key: - raise HTTPException(status_code=400, detail="Resos API key not configured") - - async with ResosAPIClient(settings.resos_api_key) as client: - fields = await client.get_custom_field_definitions() - - return {"custom_fields": fields} - - -@router.get("/opening-hours") -async def fetch_opening_hours( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch opening hours/service periods from Resos API (GET request only)""" - import logging - logger = logging.getLogger(__name__) - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one() - - if not settings.resos_api_key: - raise HTTPException(status_code=400, detail="Resos API key not configured") - - async with ResosAPIClient(settings.resos_api_key) as client: - hours = await client.get_opening_hours() - - # Log the raw response to understand structure - logger.info(f"Raw opening hours from Resos API: {len(hours)} periods") - - # Filter out special/one-off periods - only return regular service periods - # Filter on 'special' field: True = one-off events, False = recurring service periods - regular_hours = [h for h in hours if h.get('special') == False] - - # Day of week mapping (Resos uses 1=Monday, 7=Sunday) - day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] - - # Transform time format: Resos uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00) - # Convert to 'startTime' and 'endTime' in HH:MM format for frontend - for hour in regular_hours: - # Add day of week name - day_num = hour.get('day', 0) - if 1 <= day_num <= 7: - hour['dayName'] = day_names[day_num] - else: - hour['dayName'] = 'Unknown' - - if 'open' in hour: - open_val = hour['open'] - hours_part = open_val // 100 - mins_part = open_val % 100 - hour['startTime'] = f"{hours_part:02d}:{mins_part:02d}" - - if 'close' in hour: - close_val = hour['close'] - hours_part = close_val // 100 - mins_part = close_val % 100 - hour['endTime'] = f"{hours_part:02d}:{mins_part:02d}" - - # Auto-calculate actual end time by subtracting booking duration - # Resos extends close time to allow late bookings - seating = hour.get('seating', {}) - duration = seating.get('duration', 0) # Duration in minutes - if duration > 0: - # Convert close time to minutes - close_minutes = hours_part * 60 + mins_part - # Subtract booking duration - actual_end_minutes = close_minutes - duration - # Convert back to HH:MM - actual_hours = actual_end_minutes // 60 - actual_mins = actual_end_minutes % 60 - hour['actualEnd'] = f"{actual_hours:02d}:{actual_mins:02d}" - hour['bookingDuration'] = duration - - # Sort by day of week first, then by open time within each day - regular_hours.sort(key=lambda h: (h.get('day', 0), h.get('open', 0))) - - logger.info(f"After filtering: {len(regular_hours)} regular periods (filtered out {len(hours) - len(regular_hours)} special periods)") - - return {"opening_hours": regular_hours} - - -@router.get("/opening-hours/{date}") -async def get_opening_hours_for_date( - date: str, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get opening hours configuration for a specific date - - Returns opening hour periods with their times and intervals. - Used by Gantt chart to determine time range and closed periods. - """ - import logging - logger = logging.getLogger(__name__) - - # Parse date - try: - query_date = datetime.fromisoformat(date).date() - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format") - - # Get kitchen settings - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if not settings or not settings.resos_api_key: - logger.warning("No Resos API key configured") - return [] - - # Fetch opening hours from Resos API - async with ResosAPIClient(settings.resos_api_key) as client: - hours = await client.get_opening_hours() - - # Filter to regular (non-special) hours and format for Gantt chart - day_of_week = query_date.isoweekday() # Monday=1, Sunday=7 - - formatted_hours = [] - for hour in hours: - # Skip special/one-off periods - if hour.get('special') == True: - continue - - # Check if this opening hour applies to the query date's day of week - hour_day = hour.get('day', 0) - if hour_day != day_of_week: - continue - - # Get open and close times (already in HHMM format from API) - open_time = hour.get('open', 0) - close_time = hour.get('close', 0) - - # Find service type from mapping - resos_id = hour.get('id', '') - service_type = None - if settings.resos_opening_hours_mapping: - for mapping in settings.resos_opening_hours_mapping: - if isinstance(mapping, dict) and mapping.get('resos_id') == resos_id: - service_type = mapping.get('service_type', '') - break - - formatted_hours.append({ - "name": hour.get('name', ''), - "service_type": service_type or hour.get('name', ''), - "open": open_time, - "close": close_time, - "is_special": False - }) - - logger.info(f"Found {len(formatted_hours)} opening hours for {query_date} (day {day_of_week})") - return formatted_hours - - -# ============ Sync Endpoints ============ - -@router.post("/sync/upcoming") -async def sync_upcoming( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Manual upcoming sync (next 7 days) - triggers immediately and updates last_upcoming_sync timestamp""" - sync_service = ResosSyncService(current_user.kitchen_id, db) - result = await sync_service.run_upcoming_sync() - logger.info(f"[Resos] Manual upcoming sync completed for kitchen {current_user.kitchen_id}") - return result - - -@router.post("/sync/forecast") -async def sync_forecast( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Manual forecast sync (next 60 days)""" - sync_service = ResosSyncService(current_user.kitchen_id, db) - - today = date.today() - to_date = today + timedelta(days=60) - - result = await sync_service.sync_bookings(today, to_date, is_forecast=True) - return result - - -@router.post("/sync/historical") -async def sync_historical( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Manual historical sync""" - sync_service = ResosSyncService(current_user.kitchen_id, db) - result = await sync_service.sync_bookings(from_date, to_date, is_forecast=False) - return result - - -@router.post("/sync/opening-hours") -async def sync_opening_hours( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Sync opening hours from Resos API to local database""" - import logging - logger = logging.getLogger(__name__) - - logger.info(f"Starting opening hours sync for kitchen {current_user.kitchen_id}") - - sync_service = ResosSyncService(current_user.kitchen_id, db) - count = await sync_service.sync_opening_hours() - - logger.info(f"Successfully synced {count} opening hours to database") - - return { - "message": f"Successfully synced {count} opening hours", - "count": count - } - - -# ============ Data Retrieval Endpoints ============ - -@router.get("/daily-stats") -async def get_daily_stats( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> list[DailyStatsResponse]: - """Get daily stats for date range""" - result = await db.execute( - select(ResosDailyStats).where( - and_( - ResosDailyStats.kitchen_id == current_user.kitchen_id, - ResosDailyStats.date >= from_date, - ResosDailyStats.date <= to_date - ) - ).order_by(ResosDailyStats.date) - ) - - stats = result.scalars().all() - - return [ - DailyStatsResponse( - date=stat.date.isoformat(), - total_bookings=stat.total_bookings, - total_covers=stat.total_covers, - service_breakdown=stat.service_breakdown or [], - flagged_booking_count=stat.flagged_booking_count, - unique_flag_types=stat.unique_flag_types, - is_forecast=stat.is_forecast - ) - for stat in stats - ] - - -@router.get("/bookings/{booking_date}") -async def get_bookings_for_date( - booking_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> list[BookingResponse]: - """Get all bookings for a specific date, excluding cancelled/deleted""" - excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'] - result = await db.execute( - select(ResosBooking).where( - and_( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date == booking_date, - ~func.lower(ResosBooking.status).in_(excluded_statuses) - ) - ).order_by(ResosBooking.booking_time) - ) - - bookings = result.scalars().all() - - return [ - BookingResponse( - id=b.id, - resos_booking_id=b.resos_booking_id, - booking_date=b.booking_date.isoformat(), - booking_time=b.booking_time.isoformat(), - people=b.people, - status=b.status, - seating_area=b.seating_area, - hotel_booking_number=b.hotel_booking_number, - is_hotel_guest=b.is_hotel_guest, - is_dbb=b.is_dbb, - is_package=b.is_package, - allergies=b.allergies, - notes=b.notes, - opening_hour_name=b.opening_hour_name, - is_flagged=b.is_flagged, - flag_reasons=b.flag_reasons - ) - for b in bookings - ] - - -# ============ Dashboard Endpoint ============ - -@router.get("/dashboard/today-tomorrow") -async def get_dashboard_covers( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> dict: - """Get today, tomorrow, and day after tomorrow covers for dashboard""" - import logging - logger = logging.getLogger(__name__) - - today = date.today() - tomorrow = today + timedelta(days=1) - day_after = today + timedelta(days=2) - - # Day names for display - day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] - - result = await db.execute( - select(ResosDailyStats).where( - and_( - ResosDailyStats.kitchen_id == current_user.kitchen_id, - ResosDailyStats.date.in_([today, tomorrow, day_after]) - ) - ) - ) - - stats = {stat.date: stat for stat in result.scalars().all()} - - def build_response(target_date: date, day_label: str) -> Optional[dict]: - if target_date not in stats: - return { - 'date': target_date.isoformat(), - 'day_label': day_label, - 'total_bookings': 0, - 'total_covers': 0, - 'service_breakdown': [], - 'has_flagged_bookings': False, - 'unique_flag_types': [] - } - stat = stats[target_date] - logger.info(f"Date {target_date}: unique_flag_types={stat.unique_flag_types}, type={type(stat.unique_flag_types)}, flagged_count={stat.flagged_booking_count}") - return { - 'date': stat.date.isoformat(), - 'day_label': day_label, - 'total_bookings': stat.total_bookings, - 'total_covers': stat.total_covers, - 'service_breakdown': stat.service_breakdown or [], - 'has_flagged_bookings': stat.flagged_booking_count > 0, - 'unique_flag_types': stat.unique_flag_types or [] - } - - return { - 'today': build_response(today, 'Today'), - 'tomorrow': build_response(tomorrow, 'Tomorrow'), - 'day_after': build_response(day_after, day_names[day_after.weekday()]) - } - - -# ============ Stats Report Endpoint (Phase 8) ============ - -@router.get("/stats") -async def get_bookings_stats( - from_date: date, - to_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> dict: - """ - Get comprehensive booking statistics with spend analysis (Phase 8). - - Calculates: - - Total bookings and covers - - Average lead time (days between booking and reservation) - - Spend analysis (food/beverage/total) matched from SambaPOS - - Resident vs non-resident split (by covers and spend) - - Daily breakdown - - Service period breakdown with avg spend per cover - """ - from services.resos_stats import ResosStatsService - - # Get summary metrics from resos_bookings - result = await db.execute( - select( - func.count(ResosBooking.id).label('total_bookings'), - func.sum(ResosBooking.people).label('total_covers'), - func.avg( - ResosBooking.booking_date - func.cast(ResosBooking.booked_at, Date) - ).label('avg_lead_time_days') - ).where( - and_( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date >= from_date, - ResosBooking.booking_date <= to_date, - func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved']) - ) - ) - ) - summary = result.first() - - total_bookings = summary.total_bookings or 0 - total_covers = summary.total_covers or 0 - avg_lead_time_days = float(summary.avg_lead_time_days) if summary.avg_lead_time_days else 0.0 - - # Get resident/non-resident cover counts - result = await db.execute( - select( - func.sum(case((ResosBooking.is_hotel_guest == True, ResosBooking.people), else_=0)).label('resident_covers'), - func.sum(case((ResosBooking.is_hotel_guest == False, ResosBooking.people), else_=0)).label('non_resident_covers') - ).where( - and_( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date >= from_date, - ResosBooking.booking_date <= to_date, - func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved']) - ) - ) - ) - resident_split = result.first() - resident_covers_booking = resident_split.resident_covers or 0 - non_resident_covers_booking = resident_split.non_resident_covers or 0 - - # Get spend statistics (Phase 8 integration) - stats_service = ResosStatsService(current_user.kitchen_id, db) - spend_stats = await stats_service.get_spend_statistics(from_date, to_date) - - # Calculate resident percentages - resident_pct_covers = (resident_covers_booking / total_covers * 100) if total_covers > 0 else 0.0 - total_spend = spend_stats['total_spend'] - resident_pct_spend = (spend_stats['resident_spend'] / total_spend * 100) if total_spend > 0 else 0.0 - - # Combine booking data with spend data - return { - 'summary': { - 'total_bookings': total_bookings, - 'total_covers': total_covers, - 'avg_lead_time_days': round(avg_lead_time_days, 1), - 'resident_covers': resident_covers_booking, - 'non_resident_covers': non_resident_covers_booking, - 'resident_pct_covers': round(resident_pct_covers, 1), - 'resident_pct_spend': round(resident_pct_spend, 1) - }, - 'spend': { - 'total_spend': spend_stats['total_spend'], - 'food_spend': spend_stats['food_spend'], - 'beverage_spend': spend_stats['beverage_spend'], - 'resident_spend': spend_stats['resident_spend'], - 'non_resident_spend': spend_stats['non_resident_spend'], - 'total_tickets': spend_stats['total_tickets'], - 'resident_tickets': spend_stats['resident_tickets'], - 'non_resident_tickets': spend_stats['non_resident_tickets'], - 'matched_to_resos': spend_stats['matched_to_resos'], - 'unmatched_to_resos': spend_stats['unmatched_to_resos'], - 'classification': spend_stats['classification'] - }, - 'daily_breakdown': spend_stats['daily_breakdown'], - 'service_period_breakdown': spend_stats['service_period_breakdown'], - 'daily_service_breakdown': spend_stats['daily_service_breakdown'] - } - - -# ============ Resident Covers for Budget ============ - -@router.get("/resident-covers") -async def get_resident_covers( - start_date: date, - end_date: date, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -) -> dict: - """ - Get per-date, per-service-period hotel guest (resident) cover counts. - Used by the Budget page to show 'inc Residents' row in the forecast table. - """ - excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'] - - # Query bookings grouped by date and opening_hour - result = await db.execute( - select( - ResosBooking.booking_date, - ResosBooking.opening_hour_id, - ResosBooking.opening_hour_name, - func.sum(ResosBooking.people).label('resident_covers'), - func.count(ResosBooking.id).label('resident_bookings'), - ).where( - and_( - ResosBooking.kitchen_id == current_user.kitchen_id, - ResosBooking.booking_date >= start_date, - ResosBooking.booking_date <= end_date, - ResosBooking.is_hotel_guest == True, - ~func.lower(ResosBooking.status).in_(excluded_statuses) - ) - ).group_by( - ResosBooking.booking_date, - ResosBooking.opening_hour_id, - ResosBooking.opening_hour_name - ) - ) - - # Get kitchen settings for service type mapping - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None - - service_type_map = {} - if opening_hours_mapping: - for mapping in opening_hours_mapping: - if isinstance(mapping, dict): - resos_id = mapping.get('resos_id', '') - service_type = mapping.get('service_type', '') - if resos_id and service_type: - service_type_map[resos_id] = service_type - - # Build per-date, per-period response with covers and booking counts - dates: dict[str, dict[str, dict[str, int]]] = {} - for row in result: - date_str = row.booking_date.isoformat() - if date_str not in dates: - dates[date_str] = {} - - opening_hour_id = row.opening_hour_id - opening_hour_name = row.opening_hour_name or 'Unknown' - service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name - period = service_type.lower() if service_type else 'unknown' - - # Accumulate in case multiple opening hours map to same period - if period not in dates[date_str]: - dates[date_str][period] = {"covers": 0, "bookings": 0} - dates[date_str][period]["covers"] += row.resident_covers or 0 - dates[date_str][period]["bookings"] += row.resident_bookings or 0 - - return {"dates": dates} diff --git a/backend/api/sambapos.py b/backend/api/sambapos.py deleted file mode 100644 index 06d9c86..0000000 --- a/backend/api/sambapos.py +++ /dev/null @@ -1,690 +0,0 @@ -""" -SambaPOS API Endpoints - -Handles SambaPOS MSSQL configuration and category management. -""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from auth import get_current_user, require_cap -from services.sambapos_api import SambaPOSClient - -router = APIRouter() - - -# ============ Pydantic Schemas ============ - -class SambaPOSSettingsResponse(BaseModel): - sambapos_db_host: str | None - sambapos_db_port: int | None - sambapos_db_name: str | None - sambapos_db_username: str | None - sambapos_db_password_set: bool - sambapos_tracked_categories: list[str] - sambapos_excluded_items: list[str] - - class Config: - from_attributes = True - - -class SambaPOSSettingsUpdate(BaseModel): - sambapos_db_host: str | None = None - sambapos_db_port: int | None = None - sambapos_db_name: str | None = None - sambapos_db_username: str | None = None - sambapos_db_password: str | None = None - - -class CategoryResponse(BaseModel): - id: int - name: str - - -class MenuItemResponse(BaseModel): - name: str - category: str - - -class TrackedCategoriesUpdate(BaseModel): - categories: list[str] - - -class ExcludedItemsUpdate(BaseModel): - items: list[str] - - -# ============ Settings Endpoints ============ - -@router.get("/settings", response_model=SambaPOSSettingsResponse) -async def get_sambapos_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get SambaPOS connection settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Parse tracked categories from comma-separated string - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - # Parse excluded items from comma-separated string - excluded_items = [] - if settings.sambapos_excluded_items: - excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] - - return SambaPOSSettingsResponse( - sambapos_db_host=settings.sambapos_db_host, - sambapos_db_port=settings.sambapos_db_port, - sambapos_db_name=settings.sambapos_db_name, - sambapos_db_username=settings.sambapos_db_username, - sambapos_db_password_set=bool(settings.sambapos_db_password), - sambapos_tracked_categories=tracked_categories, - sambapos_excluded_items=excluded_items - ) - - -@router.patch("/settings", response_model=SambaPOSSettingsResponse) -async def update_sambapos_settings( - update: SambaPOSSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update SambaPOS connection settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Update fields - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - if value is not None: - setattr(settings, field, value) - - await db.commit() - await db.refresh(settings) - - # Parse tracked categories from comma-separated string - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - # Parse excluded items from pipe-separated string - excluded_items = [] - if settings.sambapos_excluded_items: - excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] - - return SambaPOSSettingsResponse( - sambapos_db_host=settings.sambapos_db_host, - sambapos_db_port=settings.sambapos_db_port, - sambapos_db_name=settings.sambapos_db_name, - sambapos_db_username=settings.sambapos_db_username, - sambapos_db_password_set=bool(settings.sambapos_db_password), - sambapos_tracked_categories=tracked_categories, - sambapos_excluded_items=excluded_items - ) - - -@router.post("/test-connection") -async def test_sambapos_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test SambaPOS database connection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - result = await client.test_connection() - - if result["success"]: - return {"status": "success", "message": "SambaPOS connection successful"} - else: - raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}") - - -# ============ Categories Endpoints ============ - -@router.get("/categories", response_model=list[CategoryResponse]) -async def get_sambapos_categories( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch all menu categories from SambaPOS database""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - categories = await client.get_categories() - return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}") - - -@router.get("/tracked-categories") -async def get_tracked_categories( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get list of category names enabled for top sellers""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Parse tracked categories from comma-separated string - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - return {"categories": tracked_categories} - - -@router.patch("/tracked-categories") -async def update_tracked_categories( - update: TrackedCategoriesUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update which categories are included in top sellers""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Store as comma-separated string - settings.sambapos_tracked_categories = ','.join(update.categories) - - await db.commit() - - return {"status": "success", "categories": update.categories} - - -@router.get("/debug/menuitems") -async def debug_menuitems( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Debug endpoint to explore MenuItems table structure""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.sambapos_db_password: - raise HTTPException(status_code=400, detail="SambaPOS not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - return await client.debug_menu_items() - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -# ============ Menu Items Endpoints ============ - -@router.get("/menu-items", response_model=list[MenuItemResponse]) -async def get_menu_items( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch all unique menu item names with their categories for exclusion selection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - # Get tracked categories to filter menu items - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None) - return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}") - - -class MenuItemWithPortionResponse(BaseModel): - menu_item_name: str - portion_name: str - category: str - on_pos_menu: bool - - -@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse]) -async def get_menu_items_with_portions( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch all menu items with their portion names, grouped by Kitchen Course category.""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - # Get tracked categories to filter - tracked_categories = [] - if settings.sambapos_tracked_categories: - tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - items = await client.get_menu_items_with_portions( - categories=tracked_categories if tracked_categories else None - ) - return [ - MenuItemWithPortionResponse( - menu_item_name=item["menu_item_name"], - portion_name=item["portion_name"], - category=item["category"], - on_pos_menu=item.get("on_pos_menu", False) - ) - for item in items - ] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}") - - -@router.get("/excluded-items") -async def get_excluded_items( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get list of menu item names excluded from top sellers report""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Parse excluded items from pipe-separated string - excluded_items = [] - if settings.sambapos_excluded_items: - excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] - - return {"items": excluded_items} - - -@router.patch("/excluded-items") -async def update_excluded_items( - update: ExcludedItemsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update which menu item GroupCodes are excluded from top sellers report""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Store as pipe-separated string (to allow commas in names) - settings.sambapos_excluded_items = '|'.join(update.items) - - await db.commit() - - return {"status": "success", "items": update.items} - - -# ============ Group Codes Endpoints ============ - -class GroupCodeResponse(BaseModel): - name: str - - -@router.get("/group-codes", response_model=list[GroupCodeResponse]) -async def get_group_codes( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch all distinct GroupCode values from MenuItems table for exclusion selection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - group_codes = await client.get_menu_group_codes() - return [GroupCodeResponse(name=gc["name"]) for gc in group_codes] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}") - - -# ============ GL Codes Endpoints (Phase 8) ============ - -class GLCodeResponse(BaseModel): - code: str - - -class GLCodesUpdate(BaseModel): - food_codes: list[str] - beverage_codes: list[str] - - -@router.get("/gl-codes", response_model=list[GLCodeResponse]) -async def get_gl_codes( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Fetch all unique GL codes from ProductTag custom tags for food/beverage classification""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - gl_codes = await client.get_gl_codes() - return [GLCodeResponse(code=gc["code"]) for gc in gl_codes] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}") - - -@router.get("/gl-codes/selected") -async def get_selected_gl_codes( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get currently selected food and beverage GL codes""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Parse food GL codes from comma-separated string - food_codes = [] - if settings.sambapos_food_gl_codes: - food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()] - - # Parse beverage GL codes from comma-separated string - beverage_codes = [] - if settings.sambapos_beverage_gl_codes: - beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()] - - return { - "food_codes": food_codes, - "beverage_codes": beverage_codes - } - - -@router.patch("/gl-codes") -async def update_gl_codes( - update: GLCodesUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update food and beverage GL code selections""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - # Store as comma-separated strings - settings.sambapos_food_gl_codes = ','.join(update.food_codes) - settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes) - - await db.commit() - - return { - "status": "success", - "food_codes": update.food_codes, - "beverage_codes": update.beverage_codes - } - - -@router.get("/debug/custom-tags") -async def debug_custom_tags( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Debug endpoint to see sample CustomTags from MenuItems""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - return await client.debug_menu_items() - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}") - - -@router.get("/debug/zero-price-order-states") -async def debug_zero_price_order_states( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Debug endpoint: returns sample OrderStates blobs for zero-priced orders. - Use this to discover the JSON structure and identify where original/package price is stored. - """ - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.sambapos_db_password: - raise HTTPException(status_code=400, detail="SambaPOS not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - return await client.debug_zero_price_order_states() - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.get("/debug/table-schema") -async def debug_table_schema( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Debug endpoint to inspect column names in SambaPOS tables""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Settings not found") - - if not all([ - settings.sambapos_db_host, - settings.sambapos_db_name, - settings.sambapos_db_username, - settings.sambapos_db_password - ]): - raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") - - client = SambaPOSClient( - host=settings.sambapos_db_host, - port=settings.sambapos_db_port or 1433, - database=settings.sambapos_db_name, - username=settings.sambapos_db_username, - password=settings.sambapos_db_password - ) - - try: - return await client.debug_table_schema() - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}") diff --git a/backend/api/search.py b/backend/api/search.py deleted file mode 100644 index 68eb7ec..0000000 --- a/backend/api/search.py +++ /dev/null @@ -1,744 +0,0 @@ -""" -Search API endpoints for searching invoices, line items, and product definitions. -""" -from datetime import date, datetime, timedelta -from decimal import Decimal -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, or_, desc -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.invoice import Invoice, InvoiceStatus -from models.line_item import LineItem -from models.supplier import Supplier -from models.product_definition import ProductDefinition -from models.settings import KitchenSettings -from auth import get_current_user, require_cap -from services.price_history import PriceHistoryService - -router = APIRouter(prefix="/api/search", tags=["search"]) - - -# ============ Response Models ============ - -class GroupSummary(BaseModel): - name: str - count: int - total: Optional[Decimal] = None - - -class InvoiceSearchItem(BaseModel): - id: int - invoice_number: Optional[str] - invoice_date: Optional[date] - total: Optional[Decimal] - net_total: Optional[Decimal] - supplier_id: Optional[int] - supplier_name: Optional[str] - vendor_name: Optional[str] - status: str - document_type: Optional[str] - - class Config: - from_attributes = True - - -class InvoiceSearchResponse(BaseModel): - items: List[InvoiceSearchItem] - total_count: int - grouped_by: Optional[str] - groups: Optional[List[GroupSummary]] - - -class LineItemSearchItem(BaseModel): - product_code: Optional[str] - description: Optional[str] - supplier_id: Optional[int] - supplier_name: Optional[str] - unit: Optional[str] - most_recent_price: Optional[Decimal] - earliest_price_in_period: Optional[Decimal] - price_change_percent: Optional[float] - price_change_status: str - total_quantity: Optional[Decimal] - occurrence_count: int - most_recent_invoice_id: Optional[int] - most_recent_invoice_number: Optional[str] - most_recent_date: Optional[date] - has_definition: bool - portions_per_unit: Optional[int] - pack_quantity: Optional[int] - most_recent_line_item_id: Optional[int] = None - most_recent_line_number: Optional[int] = None - most_recent_raw_content: Optional[str] = None - most_recent_pack_quantity: Optional[int] = None - most_recent_unit_size: Optional[Decimal] = None - most_recent_unit_size_type: Optional[str] = None - # Ingredient mapping info - ingredient_id: Optional[int] = None - ingredient_name: Optional[str] = None - ingredient_standard_unit: Optional[str] = None - price_per_std_unit: Optional[Decimal] = None - - class Config: - from_attributes = True - - -class LineItemSearchResponse(BaseModel): - items: List[LineItemSearchItem] - total_count: int - grouped_by: Optional[str] - groups: Optional[List[GroupSummary]] - - -class DefinitionSearchItem(BaseModel): - id: int - product_code: Optional[str] - description_pattern: Optional[str] - supplier_id: Optional[int] - supplier_name: Optional[str] - pack_quantity: Optional[int] - unit_size: Optional[Decimal] - unit_size_type: Optional[str] - portions_per_unit: Optional[int] - portion_description: Optional[str] - source_invoice_id: Optional[int] - source_invoice_number: Optional[str] - most_recent_price: Optional[Decimal] - updated_at: datetime - - class Config: - from_attributes = True - - -class DefinitionSearchResponse(BaseModel): - items: List[DefinitionSearchItem] - total_count: int - - -class PriceHistoryPointResponse(BaseModel): - date: date - price: Decimal - invoice_id: int - invoice_number: Optional[str] - quantity: Optional[Decimal] - - -class LineItemHistoryResponse(BaseModel): - product_code: Optional[str] - description: Optional[str] - supplier_id: int - supplier_name: Optional[str] - price_history: List[PriceHistoryPointResponse] - total_occurrences: int - total_quantity: Decimal - avg_qty_per_invoice: Decimal - avg_qty_per_week: Decimal - avg_qty_per_month: Decimal - current_price: Optional[Decimal] - price_change_status: str - - -class AcknowledgePriceRequest(BaseModel): - product_code: Optional[str] = None - description: Optional[str] = None - supplier_id: int - new_price: Decimal - source_invoice_id: Optional[int] = None - source_line_item_id: Optional[int] = None - - -class AcknowledgePriceResponse(BaseModel): - id: int - acknowledged_price: Decimal - acknowledged_at: datetime - - -# ============ Invoice Search ============ - -@router.get("/invoices", response_model=InvoiceSearchResponse) -async def search_invoices( - q: str = "", - include_line_items: bool = False, - supplier_id: Optional[int] = None, - status: Optional[str] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - group_by: Optional[str] = None, - limit: int = Query(default=100, le=500), - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Search invoices with optional filters. - - - q: Search term (invoice_number, vendor_name) - - include_line_items: Also search line item product_code/description - - supplier_id: Filter by supplier - - status: Filter by status (pending, confirmed, etc.) - - date_from/date_to: Date range (default: last 30 days) - - group_by: "supplier" or "month" for grouped results - """ - # Default date range: last 30 days - if date_to is None: - date_to = date.today() - if date_from is None: - date_from = date_to - timedelta(days=30) - - # Build base conditions - conditions = [ - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.invoice_date >= date_from, - Invoice.invoice_date <= date_to, - ] - - if supplier_id: - conditions.append(Invoice.supplier_id == supplier_id) - - if status: - conditions.append(Invoice.status == status) - - # Search filter - if q: - search_pattern = f"%{q}%" - search_conditions = [ - Invoice.invoice_number.ilike(search_pattern), - Invoice.vendor_name.ilike(search_pattern), - ] - - if include_line_items: - # Need to join line items and search there too - line_item_subquery = ( - select(LineItem.invoice_id) - .where(or_( - LineItem.product_code.ilike(search_pattern), - LineItem.description.ilike(search_pattern) - )) - .distinct() - ) - search_conditions.append(Invoice.id.in_(line_item_subquery)) - - conditions.append(or_(*search_conditions)) - - # Get total count - count_query = select(func.count(Invoice.id)).where(and_(*conditions)) - count_result = await db.execute(count_query) - total_count = count_result.scalar() or 0 - - # Get invoices with supplier name - query = ( - select(Invoice, Supplier.name.label('supplier_name')) - .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) - .where(and_(*conditions)) - .order_by(desc(Invoice.invoice_date)) - .limit(limit) - .offset(offset) - ) - result = await db.execute(query) - rows = result.fetchall() - - items = [ - InvoiceSearchItem( - id=row.Invoice.id, - invoice_number=row.Invoice.invoice_number, - invoice_date=row.Invoice.invoice_date, - total=row.Invoice.total, - net_total=row.Invoice.net_total, - supplier_id=row.Invoice.supplier_id, - supplier_name=row.supplier_name, - vendor_name=row.Invoice.vendor_name, - status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status, - document_type=row.Invoice.document_type - ) - for row in rows - ] - - # Handle grouping - groups = None - if group_by == "supplier": - group_query = ( - select( - Supplier.name, - func.count(Invoice.id).label('count'), - func.sum(Invoice.net_total).label('total') - ) - .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) - .where(and_(*conditions)) - .group_by(Supplier.name) - .order_by(desc('total')) - ) - group_result = await db.execute(group_query) - groups = [ - GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) - for row in group_result.fetchall() - ] - elif group_by == "month": - group_query = ( - select( - func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'), - func.count(Invoice.id).label('count'), - func.sum(Invoice.net_total).label('total') - ) - .where(and_(*conditions)) - .group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM')) - .order_by(desc('month')) - ) - group_result = await db.execute(group_query) - groups = [ - GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) - for row in group_result.fetchall() - ] - - return InvoiceSearchResponse( - items=items, - total_count=total_count, - grouped_by=group_by, - groups=groups - ) - - -# ============ Line Items Search (Consolidated) ============ - -@router.get("/line-items", response_model=LineItemSearchResponse) -async def search_line_items( - q: str = "", - supplier_id: Optional[int] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - group_by: Optional[str] = None, - mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"), - limit: int = Query(default=100, le=500), - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Search line items with consolidation. - - Returns DISTINCT line items by (product_code OR description + supplier), - with most recent price, price change status, total quantity, occurrence count. - """ - price_service = PriceHistoryService(db, current_user.kitchen_id) - - items_data, total_count = await price_service.get_consolidated_line_items( - search_query=q if q else None, - supplier_id=supplier_id, - date_from=date_from, - date_to=date_to, - limit=limit, - offset=offset - ) - - items = [LineItemSearchItem(**item) for item in items_data] - - # Filter by ingredient mapping status - if mapped == 'yes': - items = [i for i in items if i.ingredient_id is not None] - total_count = len(items) - elif mapped == 'no': - items = [i for i in items if i.ingredient_id is None] - total_count = len(items) - - # Handle grouping (for UI display) - groups = None - if group_by == "supplier": - # Group items by supplier - supplier_groups = {} - for item in items: - name = item.supplier_name or "Unknown" - if name not in supplier_groups: - supplier_groups[name] = {"count": 0, "total": Decimal(0)} - supplier_groups[name]["count"] += item.occurrence_count - if item.total_quantity: - supplier_groups[name]["total"] += item.total_quantity - - groups = [ - GroupSummary(name=name, count=data["count"], total=data["total"]) - for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"]) - ] - - return LineItemSearchResponse( - items=items, - total_count=total_count, - grouped_by=group_by, - groups=groups - ) - - -# ============ Definitions Search ============ - -@router.get("/definitions", response_model=DefinitionSearchResponse) -async def search_definitions( - q: str = "", - supplier_id: Optional[int] = None, - has_portions: Optional[bool] = None, - limit: int = Query(default=100, le=500), - offset: int = 0, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Search product definitions. - - - q: Search term (product_code, description_pattern) - - supplier_id: Filter by supplier - - has_portions: Filter by whether portions_per_unit is set - """ - conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id] - - if supplier_id: - conditions.append(ProductDefinition.supplier_id == supplier_id) - - if q: - search_pattern = f"%{q}%" - conditions.append(or_( - ProductDefinition.product_code.ilike(search_pattern), - ProductDefinition.description_pattern.ilike(search_pattern) - )) - - if has_portions is not None: - if has_portions: - conditions.append(ProductDefinition.portions_per_unit.isnot(None)) - else: - conditions.append(ProductDefinition.portions_per_unit.is_(None)) - - # Get total count - count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions)) - count_result = await db.execute(count_query) - total_count = count_result.scalar() or 0 - - # Get definitions with supplier name and source invoice number - query = ( - select( - ProductDefinition, - Supplier.name.label('supplier_name'), - Invoice.invoice_number.label('source_invoice_number') - ) - .outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id) - .outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id) - .where(and_(*conditions)) - .order_by(desc(ProductDefinition.updated_at)) - .limit(limit) - .offset(offset) - ) - result = await db.execute(query) - rows = result.fetchall() - - items = [] - for row in rows: - definition = row.ProductDefinition - - # Get most recent price from matching line items - most_recent_price = None - price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] - - if definition.supplier_id: - price_conditions.append(Invoice.supplier_id == definition.supplier_id) - - if definition.product_code: - price_conditions.append(LineItem.product_code == definition.product_code) - elif definition.description_pattern: - price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) - - if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id - price_query = ( - select(LineItem.unit_price) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where(and_(*price_conditions)) - .order_by(desc(Invoice.invoice_date)) - .limit(1) - ) - price_result = await db.execute(price_query) - price_row = price_result.scalar_one_or_none() - if price_row is not None: - most_recent_price = price_row - - items.append(DefinitionSearchItem( - id=definition.id, - product_code=definition.product_code, - description_pattern=definition.description_pattern, - supplier_id=definition.supplier_id, - supplier_name=row.supplier_name, - pack_quantity=definition.pack_quantity, - unit_size=definition.unit_size, - unit_size_type=definition.unit_size_type, - portions_per_unit=definition.portions_per_unit, - portion_description=definition.portion_description, - source_invoice_id=definition.source_invoice_id, - source_invoice_number=row.source_invoice_number, - most_recent_price=most_recent_price, - updated_at=definition.updated_at - )) - - return DefinitionSearchResponse(items=items, total_count=total_count) - - -# ============ Line Item History ============ - -@router.get("/line-items/history", response_model=LineItemHistoryResponse) -async def get_line_item_history( - supplier_id: int, - product_code: Optional[str] = None, - description: Optional[str] = None, - unit: Optional[str] = None, - date_from: Optional[date] = None, - date_to: Optional[date] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Get price and quantity history for a specific line item. - - Used by the history modal to show price chart and stats. - """ - if not product_code and not description: - raise HTTPException( - status_code=400, - detail="Either product_code or description is required" - ) - - price_service = PriceHistoryService(db, current_user.kitchen_id) - history = await price_service.get_history( - supplier_id=supplier_id, - product_code=product_code, - description=description, - unit=unit, - date_from=date_from, - date_to=date_to - ) - - return LineItemHistoryResponse( - product_code=history.product_code, - description=history.description, - supplier_id=history.supplier_id, - supplier_name=history.supplier_name, - price_history=[ - PriceHistoryPointResponse( - date=point.date, - price=point.price, - invoice_id=point.invoice_id, - invoice_number=point.invoice_number, - quantity=point.quantity - ) - for point in history.price_history - ], - total_occurrences=history.total_occurrences, - total_quantity=history.total_quantity, - avg_qty_per_invoice=history.avg_qty_per_invoice, - avg_qty_per_week=history.avg_qty_per_week, - avg_qty_per_month=history.avg_qty_per_month, - current_price=history.current_price, - price_change_status=history.price_change_status - ) - - -# ============ Price Acknowledgement ============ - -@router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse) -async def acknowledge_price_change( - request: AcknowledgePriceRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Acknowledge a price change for a line item. - - Creates or updates the AcknowledgedPrice record so the price - won't be flagged as changed in future. - """ - if not request.product_code and not request.description: - raise HTTPException( - status_code=400, - detail="Either product_code or description is required" - ) - - price_service = PriceHistoryService(db, current_user.kitchen_id) - acknowledged = await price_service.acknowledge_price( - user_id=current_user.id, - supplier_id=request.supplier_id, - product_code=request.product_code, - description=request.description, - new_price=request.new_price, - source_invoice_id=request.source_invoice_id, - source_line_item_id=request.source_line_item_id - ) - - return AcknowledgePriceResponse( - id=acknowledged.id, - acknowledged_price=acknowledged.acknowledged_price, - acknowledged_at=acknowledged.acknowledged_at - ) - - -# ============ Search Settings ============ - -class SearchSettingsResponse(BaseModel): - price_change_lookback_days: int - price_change_amber_threshold: int - price_change_red_threshold: int - - -class SearchSettingsUpdate(BaseModel): - price_change_lookback_days: Optional[int] = None - price_change_amber_threshold: Optional[int] = None - price_change_red_threshold: Optional[int] = None - - -@router.get("/settings", response_model=SearchSettingsResponse) -async def get_search_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get search/price change settings.""" - result = await db.execute( - select(KitchenSettings).where( - KitchenSettings.kitchen_id == current_user.kitchen_id - ) - ) - settings = result.scalar_one_or_none() - - return SearchSettingsResponse( - price_change_lookback_days=settings.price_change_lookback_days if settings else 30, - price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10, - price_change_red_threshold=settings.price_change_red_threshold if settings else 20 - ) - - -@router.patch("/settings", response_model=SearchSettingsResponse) -async def update_search_settings( - update: SearchSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update search/price change settings.""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - result = await db.execute( - select(KitchenSettings).where( - KitchenSettings.kitchen_id == current_user.kitchen_id - ) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - if value is not None: - setattr(settings, field, value) - - await db.commit() - await db.refresh(settings) - - return SearchSettingsResponse( - price_change_lookback_days=settings.price_change_lookback_days, - price_change_amber_threshold=settings.price_change_amber_threshold, - price_change_red_threshold=settings.price_change_red_threshold - ) - - -# ============ Definition Update ============ - -class DefinitionUpdateRequest(BaseModel): - pack_quantity: Optional[int] = None - unit_size: Optional[Decimal] = None - unit_size_type: Optional[str] = None - portions_per_unit: Optional[int] = None - portion_description: Optional[str] = None - - -@router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem) -async def update_definition( - definition_id: int, - update: DefinitionUpdateRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update a product definition.""" - # Fetch the definition - result = await db.execute( - select(ProductDefinition).where( - ProductDefinition.id == definition_id, - ProductDefinition.kitchen_id == current_user.kitchen_id - ) - ) - definition = result.scalar_one_or_none() - - if not definition: - raise HTTPException(status_code=404, detail="Definition not found") - - # Update fields - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(definition, field, value) - - # Update saved_by metadata - definition.saved_by_user_id = current_user.id - - await db.commit() - await db.refresh(definition) - - # Get supplier name for response - supplier_name = None - if definition.supplier_id: - supplier_result = await db.execute( - select(Supplier.name).where(Supplier.id == definition.supplier_id) - ) - supplier_name = supplier_result.scalar_one_or_none() - - # Get source invoice number - source_invoice_number = None - if definition.source_invoice_id: - invoice_result = await db.execute( - select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id) - ) - source_invoice_number = invoice_result.scalar_one_or_none() - - # Get most recent price - most_recent_price = None - price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] - - if definition.supplier_id: - price_conditions.append(Invoice.supplier_id == definition.supplier_id) - - if definition.product_code: - price_conditions.append(LineItem.product_code == definition.product_code) - elif definition.description_pattern: - price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) - - if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id - price_query = ( - select(LineItem.unit_price) - .join(Invoice, LineItem.invoice_id == Invoice.id) - .where(and_(*price_conditions)) - .order_by(desc(Invoice.invoice_date)) - .limit(1) - ) - price_result = await db.execute(price_query) - price_row = price_result.scalar_one_or_none() - if price_row is not None: - most_recent_price = price_row - - return DefinitionSearchItem( - id=definition.id, - product_code=definition.product_code, - description_pattern=definition.description_pattern, - supplier_id=definition.supplier_id, - supplier_name=supplier_name, - pack_quantity=definition.pack_quantity, - unit_size=definition.unit_size, - unit_size_type=definition.unit_size_type, - portions_per_unit=definition.portions_per_unit, - portion_description=definition.portion_description, - source_invoice_id=definition.source_invoice_id, - source_invoice_number=source_invoice_number, - most_recent_price=most_recent_price, - updated_at=definition.updated_at - ) diff --git a/backend/api/settings.py b/backend/api/settings.py deleted file mode 100644 index a88603e..0000000 --- a/backend/api/settings.py +++ /dev/null @@ -1,798 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from pydantic import BaseModel - -from database import get_db -from models.user import User -from models.settings import KitchenSettings -from auth import get_current_user, require_cap - -router = APIRouter() - - -class SettingsResponse(BaseModel): - azure_endpoint: str | None - azure_key_set: bool # Don't expose the actual key, just whether it's set - currency_symbol: str - date_format: str - high_quantity_threshold: int - # SMTP settings - smtp_host: str | None - smtp_port: int | None - smtp_username: str | None - smtp_password_set: bool # Don't expose the actual password - smtp_use_tls: bool - smtp_from_email: str | None - smtp_from_name: str | None - support_email: str | None - # Dext integration - dext_email: str | None - dext_include_notes: bool - dext_include_non_stock: bool - dext_auto_send_enabled: bool - dext_manual_send_enabled: bool - dext_include_annotations: bool - # PDF annotation settings - pdf_annotations_enabled: bool - pdf_preview_show_annotations: bool - # OCR post-processing options - ocr_clean_product_codes: bool - ocr_filter_subtotal_rows: bool - ocr_use_weight_as_quantity: bool - # Cost distribution settings - cost_distribution_max_days: int - # LLM settings — see LLM-MANIFEST.md for removal instructions - llm_enabled: bool = False - anthropic_api_key_set: bool = False # Don't expose the actual key - llm_model: str | None = None - llm_confidence_threshold: float | None = None - llm_monthly_token_limit: int = 500000 - llm_features_enabled: dict | None = None - - class Config: - from_attributes = True - - -class SettingsUpdate(BaseModel): - azure_endpoint: str | None = None - azure_key: str | None = None - currency_symbol: str | None = None - date_format: str | None = None - high_quantity_threshold: int | None = None - # SMTP settings - smtp_host: str | None = None - smtp_port: int | None = None - smtp_username: str | None = None - smtp_password: str | None = None # Only set if provided - smtp_use_tls: bool | None = None - smtp_from_email: str | None = None - smtp_from_name: str | None = None - support_email: str | None = None - # Dext integration - dext_email: str | None = None - dext_include_notes: bool | None = None - dext_include_non_stock: bool | None = None - dext_auto_send_enabled: bool | None = None - dext_manual_send_enabled: bool | None = None - dext_include_annotations: bool | None = None - # PDF annotation settings - pdf_annotations_enabled: bool | None = None - pdf_preview_show_annotations: bool | None = None - # OCR post-processing options - ocr_clean_product_codes: bool | None = None - ocr_filter_subtotal_rows: bool | None = None - ocr_use_weight_as_quantity: bool | None = None - # Cost distribution settings - cost_distribution_max_days: int | None = None - # LLM settings — see LLM-MANIFEST.md for removal instructions - llm_enabled: bool | None = None - anthropic_api_key: str | None = None # Only set if provided - llm_model: str | None = None - llm_confidence_threshold: float | None = None - llm_monthly_token_limit: int | None = None - llm_features_enabled: dict | None = None - - -@router.get("/", response_model=SettingsResponse) -async def get_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get current kitchen settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - # Create default settings if none exist - settings = KitchenSettings( - kitchen_id=current_user.kitchen_id, - currency_symbol="£", - date_format="DD/MM/YYYY" - ) - db.add(settings) - await db.commit() - await db.refresh(settings) - - return _build_settings_response(settings) - - -def _build_settings_response(settings: KitchenSettings) -> SettingsResponse: - """Build SettingsResponse from a KitchenSettings model instance.""" - return SettingsResponse( - azure_endpoint=settings.azure_endpoint, - azure_key_set=bool(settings.azure_key), - currency_symbol=settings.currency_symbol, - date_format=settings.date_format, - high_quantity_threshold=settings.high_quantity_threshold, - # SMTP settings - smtp_host=settings.smtp_host, - smtp_port=settings.smtp_port, - smtp_username=settings.smtp_username, - smtp_password_set=bool(settings.smtp_password), - smtp_use_tls=settings.smtp_use_tls, - smtp_from_email=settings.smtp_from_email, - smtp_from_name=settings.smtp_from_name, - support_email=settings.support_email, - # Dext integration - dext_email=settings.dext_email, - dext_include_notes=settings.dext_include_notes, - dext_include_non_stock=settings.dext_include_non_stock, - dext_auto_send_enabled=settings.dext_auto_send_enabled, - dext_manual_send_enabled=settings.dext_manual_send_enabled, - dext_include_annotations=settings.dext_include_annotations, - # PDF annotation settings - pdf_annotations_enabled=settings.pdf_annotations_enabled, - pdf_preview_show_annotations=settings.pdf_preview_show_annotations, - # OCR post-processing options - ocr_clean_product_codes=settings.ocr_clean_product_codes, - ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows, - ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity, - cost_distribution_max_days=settings.cost_distribution_max_days, - # LLM settings — see LLM-MANIFEST.md for removal instructions - llm_enabled=settings.llm_enabled, - anthropic_api_key_set=bool(settings.anthropic_api_key), - llm_model=settings.llm_model, - llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None, - llm_monthly_token_limit=settings.llm_monthly_token_limit, - llm_features_enabled=settings.llm_features_enabled, - ) - - -@router.patch("/", response_model=SettingsResponse) -async def update_settings( - update: SettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update kitchen settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - # Update fields - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - if value is not None: - setattr(settings, field, value) - - await db.commit() - await db.refresh(settings) - - return _build_settings_response(settings) - - -@router.post("/test-azure") -async def test_azure_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test Azure Document Intelligence connection""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.azure_endpoint or not settings.azure_key: - raise HTTPException( - status_code=400, - detail="Azure credentials not configured" - ) - - try: - from azure.ai.formrecognizer import DocumentAnalysisClient - from azure.core.credentials import AzureKeyCredential - - client = DocumentAnalysisClient( - endpoint=settings.azure_endpoint, - credential=AzureKeyCredential(settings.azure_key) - ) - # Simple connection test - this will validate credentials - # The actual analysis would happen during invoice processing - return {"status": "success", "message": "Azure connection successful"} - except Exception as e: - raise HTTPException( - status_code=400, - detail=f"Azure connection failed: {str(e)}" - ) - - -@router.post("/test-smtp") -async def test_smtp_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test SMTP connection with current settings""" - from services.email_service import EmailService - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.smtp_host or not settings.smtp_from_email: - raise HTTPException( - status_code=400, - detail="SMTP not fully configured. Please set SMTP host and from email." - ) - - email_service = EmailService(settings) - success, message = email_service.test_connection() - - if not success: - raise HTTPException(status_code=400, detail=message) - - return {"status": "success", "message": message} - - -# ============ Kitchen Details Endpoints ============ - -class KitchenDetailsResponse(BaseModel): - kitchen_display_name: str | None = None - kitchen_address_line1: str | None = None - kitchen_address_line2: str | None = None - kitchen_city: str | None = None - kitchen_postcode: str | None = None - kitchen_phone: str | None = None - kitchen_email: str | None = None - - -class KitchenDetailsUpdate(BaseModel): - kitchen_display_name: str | None = None - kitchen_address_line1: str | None = None - kitchen_address_line2: str | None = None - kitchen_city: str | None = None - kitchen_postcode: str | None = None - kitchen_phone: str | None = None - kitchen_email: str | None = None - - -@router.get("/kitchen-details", response_model=KitchenDetailsResponse) -async def get_kitchen_details( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get kitchen details for PO letterhead""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - return KitchenDetailsResponse() - - return KitchenDetailsResponse( - kitchen_display_name=settings.kitchen_display_name, - kitchen_address_line1=settings.kitchen_address_line1, - kitchen_address_line2=settings.kitchen_address_line2, - kitchen_city=settings.kitchen_city, - kitchen_postcode=settings.kitchen_postcode, - kitchen_phone=settings.kitchen_phone, - kitchen_email=settings.kitchen_email, - ) - - -@router.patch("/kitchen-details", response_model=KitchenDetailsResponse) -async def update_kitchen_details( - update: KitchenDetailsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update kitchen details for PO letterhead""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(settings, field, value) - - await db.commit() - await db.refresh(settings) - - return KitchenDetailsResponse( - kitchen_display_name=settings.kitchen_display_name, - kitchen_address_line1=settings.kitchen_address_line1, - kitchen_address_line2=settings.kitchen_address_line2, - kitchen_city=settings.kitchen_city, - kitchen_postcode=settings.kitchen_postcode, - kitchen_phone=settings.kitchen_phone, - kitchen_email=settings.kitchen_email, - ) - - -# ============ Page Restrictions Endpoints ============ - -class PageRestrictionsResponse(BaseModel): - restricted_pages: list[str] - - -class PageRestrictionsUpdate(BaseModel): - restricted_pages: list[str] - - -@router.get("/page-restrictions", response_model=PageRestrictionsResponse) -async def get_page_restrictions( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get list of pages restricted to admin users only""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - return PageRestrictionsResponse(restricted_pages=[]) - - # Parse comma-separated list - restricted = [] - if settings.admin_restricted_pages: - restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()] - - return PageRestrictionsResponse(restricted_pages=restricted) - - -@router.patch("/page-restrictions", response_model=PageRestrictionsResponse) -async def update_page_restrictions( - update: PageRestrictionsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update list of pages restricted to admin users only (admin only)""" - if not current_user.is_admin: - raise HTTPException( - status_code=403, - detail="Only admins can modify page restrictions" - ) - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - # Store as comma-separated string - settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None - - await db.commit() - - return PageRestrictionsResponse(restricted_pages=update.restricted_pages) - - -# ============ Nextcloud Settings Endpoints ============ - -class NextcloudSettingsResponse(BaseModel): - nextcloud_host: str | None - nextcloud_username: str | None - nextcloud_password_set: bool - nextcloud_base_path: str | None - nextcloud_enabled: bool - nextcloud_delete_local: bool - - class Config: - from_attributes = True - - -class NextcloudSettingsUpdate(BaseModel): - nextcloud_host: str | None = None - nextcloud_username: str | None = None - nextcloud_password: str | None = None - nextcloud_base_path: str | None = None - nextcloud_enabled: bool | None = None - nextcloud_delete_local: bool | None = None - - -class NextcloudStatsResponse(BaseModel): - pending_count: int - archived_count: int - local_count: int - nextcloud_enabled: bool - nextcloud_configured: bool - - -class NextcloudArchiveResponse(BaseModel): - success_count: int - failed_count: int - errors: list[str] - - -@router.get("/nextcloud", response_model=NextcloudSettingsResponse) -async def get_nextcloud_settings( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get Nextcloud settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - return NextcloudSettingsResponse( - nextcloud_host=None, - nextcloud_username=None, - nextcloud_password_set=False, - nextcloud_base_path="/Kitchen Invoices", - nextcloud_enabled=False, - nextcloud_delete_local=False - ) - - return NextcloudSettingsResponse( - nextcloud_host=settings.nextcloud_host, - nextcloud_username=settings.nextcloud_username, - nextcloud_password_set=bool(settings.nextcloud_password), - nextcloud_base_path=settings.nextcloud_base_path, - nextcloud_enabled=settings.nextcloud_enabled, - nextcloud_delete_local=settings.nextcloud_delete_local - ) - - -@router.patch("/nextcloud", response_model=NextcloudSettingsResponse) -async def update_nextcloud_settings( - update: NextcloudSettingsUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update Nextcloud settings""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - # Map 'nextcloud_password' to the model field - if field == 'nextcloud_password' and value: - setattr(settings, field, value) - elif value is not None: - setattr(settings, field, value) - - await db.commit() - await db.refresh(settings) - - return NextcloudSettingsResponse( - nextcloud_host=settings.nextcloud_host, - nextcloud_username=settings.nextcloud_username, - nextcloud_password_set=bool(settings.nextcloud_password), - nextcloud_base_path=settings.nextcloud_base_path, - nextcloud_enabled=settings.nextcloud_enabled, - nextcloud_delete_local=settings.nextcloud_delete_local - ) - - -@router.post("/nextcloud/test") -async def test_nextcloud_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test Nextcloud WebDAV connection""" - from services.nextcloud_service import NextcloudService - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): - raise HTTPException(status_code=400, detail="Nextcloud not fully configured") - - nc = NextcloudService( - settings.nextcloud_host, - settings.nextcloud_username, - settings.nextcloud_password, - settings.nextcloud_base_path - ) - - success, message = await nc.test_connection() - await nc.close() - - if not success: - raise HTTPException(status_code=400, detail=message) - - return {"status": "success", "message": message} - - -@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse) -async def get_nextcloud_stats( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get Nextcloud archive statistics""" - from services.file_archival_service import FileArchivalService - - archival_service = FileArchivalService(db, current_user.kitchen_id) - stats = await archival_service.get_archive_stats() - - return NextcloudStatsResponse(**stats) - - -@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse) -async def archive_all_pending( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Manually archive all pending invoices to Nextcloud""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - from services.file_archival_service import FileArchivalService - - archival_service = FileArchivalService(db, current_user.kitchen_id) - success_count, failed_count, errors = await archival_service.archive_all_pending() - - return NextcloudArchiveResponse( - success_count=success_count, - failed_count=failed_count, - errors=errors - ) - - -@router.post("/nextcloud/archive/{invoice_id}") -async def archive_single_invoice( - invoice_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Archive a single invoice to Nextcloud (for testing)""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - from models.invoice import Invoice - from services.file_archival_service import FileArchivalService - - # Get the invoice - result = await db.execute( - select(Invoice).where( - Invoice.id == invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = result.scalar_one_or_none() - - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - if invoice.file_storage_location == "nextcloud": - return {"status": "skipped", "message": "Invoice already archived to Nextcloud"} - - # Check Nextcloud config - settings_result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = settings_result.scalar_one_or_none() - - if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): - raise HTTPException(status_code=400, detail="Nextcloud not fully configured") - - archival_service = FileArchivalService(db, current_user.kitchen_id) - success, message = await archival_service.archive_invoice_file(invoice) - - if not success: - raise HTTPException(status_code=500, detail=message) - - return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path} - - -# ============ API Access Endpoints ============ - -class ApiAccessResponse(BaseModel): - api_key: str | None - api_key_enabled: bool - - -class ApiAccessUpdate(BaseModel): - api_key_enabled: bool - - -@router.get("/api-access", response_model=ApiAccessResponse) -async def get_api_access( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get API access settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - return ApiAccessResponse(api_key=None, api_key_enabled=False) - - return ApiAccessResponse( - api_key=settings.api_key, - api_key_enabled=settings.api_key_enabled, - ) - - -@router.patch("/api-access", response_model=ApiAccessResponse) -async def update_api_access( - update: ApiAccessUpdate, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update API access settings (enable/disable)""" - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - settings.api_key_enabled = update.api_key_enabled - - await db.commit() - await db.refresh(settings) - - return ApiAccessResponse( - api_key=settings.api_key, - api_key_enabled=settings.api_key_enabled, - ) - - -@router.post("/api-access/regenerate") -async def regenerate_api_key( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Generate or regenerate the API key""" - import secrets - - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin only") - - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - settings = KitchenSettings(kitchen_id=current_user.kitchen_id) - db.add(settings) - - new_key = secrets.token_urlsafe(32) - settings.api_key = new_key - settings.api_key_enabled = True - - await db.commit() - await db.refresh(settings) - - return {"api_key": new_key, "api_key_enabled": True} - - -# ============ LLM Usage Stats Endpoints ============ -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions - - -class LlmUsageStatsResponse(BaseModel): - total_calls: int = 0 - successful_calls: int = 0 - failed_calls: int = 0 - total_input_tokens: int = 0 - total_output_tokens: int = 0 - total_tokens: int = 0 - estimated_cost_usd: float = 0.0 - cache_entries_this_month: int = 0 - - -@router.get("/llm-usage", response_model=LlmUsageStatsResponse) -async def get_llm_usage( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get aggregated LLM usage stats for the current month""" - from services.llm_service import get_usage_stats - - stats = await get_usage_stats(db, current_user.kitchen_id) - return LlmUsageStatsResponse(**stats) - - -@router.post("/test-llm") -async def test_llm_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Test Anthropic API connection with current settings""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.anthropic_api_key: - raise HTTPException( - status_code=400, - detail="Anthropic API key not configured" - ) - - if not settings.llm_enabled: - raise HTTPException( - status_code=400, - detail="LLM features are disabled. Enable them in Settings first." - ) - - try: - import anthropic - from services.llm_service import DEFAULT_LLM_MODEL - client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) - response = await client.messages.create( - model=settings.llm_model or DEFAULT_LLM_MODEL, - max_tokens=10, - messages=[{"role": "user", "content": "Say 'ok'"}], - ) - return {"status": "success", "message": f"Connection successful. Model: {response.model}"} - except anthropic.AuthenticationError: - raise HTTPException(status_code=400, detail="Authentication failed — check your API key") - except Exception as e: - raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}") - - -# LLM FEATURE — see LLM-MANIFEST.md for removal instructions -@router.get("/llm-models") -async def get_llm_models( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Fetch available models from Anthropic API.""" - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings or not settings.anthropic_api_key: - return {"models": [], "default": None, "error": "No API key configured"} - - from services.llm_service import list_available_models, DEFAULT_LLM_MODEL - models = await list_available_models(settings.anthropic_api_key) - - return { - "models": models, - "default": DEFAULT_LLM_MODEL, - "current": settings.llm_model or DEFAULT_LLM_MODEL, - } diff --git a/backend/api/suppliers.py b/backend/api/suppliers.py deleted file mode 100644 index 92bf916..0000000 --- a/backend/api/suppliers.py +++ /dev/null @@ -1,343 +0,0 @@ -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from pydantic import BaseModel - -from database import get_db, AsyncSessionLocal -from models.user import User -from models.supplier import Supplier -from models.invoice import Invoice -from auth import get_current_user, require_cap -from ocr.parser import identify_supplier - -router = APIRouter() - - -async def rematch_unmatched_invoices(kitchen_id: int): - """ - Re-run supplier matching for all invoices without a supplier. - Called after supplier create/update to match previously unmatched invoices. - """ - async with AsyncSessionLocal() as db: - # Get all invoices without a supplier that have vendor_name from OCR - result = await db.execute( - select(Invoice).where( - Invoice.kitchen_id == kitchen_id, - Invoice.supplier_id == None, - Invoice.vendor_name != None - ) - ) - invoices = result.scalars().all() - - for invoice in invoices: - if invoice.vendor_name: - supplier_id, match_type = await identify_supplier( - invoice.vendor_name, kitchen_id, db - ) - if supplier_id: - invoice.supplier_id = supplier_id - invoice.supplier_match_type = match_type - - await db.commit() - - -class SupplierCreate(BaseModel): - name: str - aliases: list[str] = [] - template_config: dict = {} - identifier_config: dict = {} - skip_dext: bool = False - order_email: Optional[str] = None - account_number: Optional[str] = None - - -class SupplierUpdate(BaseModel): - name: Optional[str] = None - aliases: Optional[list[str]] = None - template_config: Optional[dict] = None - identifier_config: Optional[dict] = None - skip_dext: Optional[bool] = None - order_email: Optional[str] = None - account_number: Optional[str] = None - - -class SupplierResponse(BaseModel): - id: int - name: str - aliases: list[str] - template_config: dict - identifier_config: dict - skip_dext: bool - order_email: Optional[str] = None - account_number: Optional[str] = None - created_at: str - - class Config: - from_attributes = True - - -@router.post("/", response_model=SupplierResponse) -async def create_supplier( - request: SupplierCreate, - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Create a new supplier with extraction templates""" - supplier = Supplier( - kitchen_id=current_user.kitchen_id, - name=request.name, - aliases=request.aliases, - template_config=request.template_config, - identifier_config=request.identifier_config, - skip_dext=request.skip_dext, - order_email=request.order_email, - account_number=request.account_number, - ) - db.add(supplier) - await db.commit() - await db.refresh(supplier) - - # Rematch unmatched invoices in background - background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) - - return SupplierResponse( - id=supplier.id, - name=supplier.name, - aliases=supplier.aliases or [], - template_config=supplier.template_config, - identifier_config=supplier.identifier_config, - skip_dext=supplier.skip_dext, - order_email=supplier.order_email, - account_number=supplier.account_number, - created_at=supplier.created_at.isoformat() - ) - - -@router.get("/", response_model=list[SupplierResponse]) -async def list_suppliers( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """List all suppliers for the current kitchen""" - result = await db.execute( - select(Supplier) - .where(Supplier.kitchen_id == current_user.kitchen_id) - .order_by(Supplier.name) - ) - suppliers = result.scalars().all() - - return [ - SupplierResponse( - id=s.id, - name=s.name, - aliases=s.aliases or [], - template_config=s.template_config, - identifier_config=s.identifier_config, - skip_dext=s.skip_dext, - order_email=s.order_email, - account_number=s.account_number, - created_at=s.created_at.isoformat() - ) - for s in suppliers - ] - - -@router.get("/{supplier_id}", response_model=SupplierResponse) -async def get_supplier( - supplier_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Get a supplier by ID""" - result = await db.execute( - select(Supplier).where( - Supplier.id == supplier_id, - Supplier.kitchen_id == current_user.kitchen_id - ) - ) - supplier = result.scalar_one_or_none() - - if not supplier: - raise HTTPException(status_code=404, detail="Supplier not found") - - return SupplierResponse( - id=supplier.id, - name=supplier.name, - aliases=supplier.aliases or [], - template_config=supplier.template_config, - identifier_config=supplier.identifier_config, - skip_dext=supplier.skip_dext, - order_email=supplier.order_email, - account_number=supplier.account_number, - created_at=supplier.created_at.isoformat() - ) - - -@router.patch("/{supplier_id}", response_model=SupplierResponse) -async def update_supplier( - supplier_id: int, - update: SupplierUpdate, - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Update a supplier's template configuration""" - result = await db.execute( - select(Supplier).where( - Supplier.id == supplier_id, - Supplier.kitchen_id == current_user.kitchen_id - ) - ) - supplier = result.scalar_one_or_none() - - if not supplier: - raise HTTPException(status_code=404, detail="Supplier not found") - - update_data = update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(supplier, field, value) - - await db.commit() - await db.refresh(supplier) - - # Rematch unmatched invoices in background - background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) - - return SupplierResponse( - id=supplier.id, - name=supplier.name, - aliases=supplier.aliases or [], - template_config=supplier.template_config, - identifier_config=supplier.identifier_config, - skip_dext=supplier.skip_dext, - order_email=supplier.order_email, - account_number=supplier.account_number, - created_at=supplier.created_at.isoformat() - ) - - -@router.delete("/{supplier_id}") -async def delete_supplier( - supplier_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Delete a supplier""" - result = await db.execute( - select(Supplier).where( - Supplier.id == supplier_id, - Supplier.kitchen_id == current_user.kitchen_id - ) - ) - supplier = result.scalar_one_or_none() - - if not supplier: - raise HTTPException(status_code=404, detail="Supplier not found") - - await db.delete(supplier) - await db.commit() - - return {"message": "Supplier deleted"} - - -class AddAliasRequest(BaseModel): - alias: str - invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact' - - -@router.post("/{supplier_id}/aliases", response_model=SupplierResponse) -async def add_supplier_alias( - supplier_id: int, - request: AddAliasRequest, - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """Add an alias to a supplier for better matching""" - result = await db.execute( - select(Supplier).where( - Supplier.id == supplier_id, - Supplier.kitchen_id == current_user.kitchen_id - ) - ) - supplier = result.scalar_one_or_none() - - if not supplier: - raise HTTPException(status_code=404, detail="Supplier not found") - - alias = request.alias.strip() - if not alias: - raise HTTPException(status_code=400, detail="Alias cannot be empty") - - # Add alias if not already present - # Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations) - current_aliases = list(supplier.aliases or []) - if alias not in current_aliases: - current_aliases.append(alias) - supplier.aliases = current_aliases - - # If invoice_id provided, update that invoice's match type to 'exact' - if request.invoice_id: - inv_result = await db.execute( - select(Invoice).where( - Invoice.id == request.invoice_id, - Invoice.kitchen_id == current_user.kitchen_id - ) - ) - invoice = inv_result.scalar_one_or_none() - if invoice and invoice.supplier_match_type == 'fuzzy': - invoice.supplier_match_type = 'exact' - - await db.commit() - await db.refresh(supplier) - - # Rematch unmatched invoices in background - background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) - - return SupplierResponse( - id=supplier.id, - name=supplier.name, - aliases=supplier.aliases or [], - template_config=supplier.template_config, - identifier_config=supplier.identifier_config, - skip_dext=supplier.skip_dext, - order_email=supplier.order_email, - account_number=supplier.account_number, - created_at=supplier.created_at.isoformat() - ) - - -@router.post("/rematch-fuzzy") -async def rematch_fuzzy_invoices( - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Clear all fuzzy-matched invoices and re-run supplier matching. - Use this after updating matching logic to fix incorrect fuzzy matches. - """ - # Count fuzzy matches before clearing - count_result = await db.execute( - select(Invoice).where( - Invoice.kitchen_id == current_user.kitchen_id, - Invoice.supplier_match_type == "fuzzy" - ) - ) - fuzzy_invoices = count_result.scalars().all() - count = len(fuzzy_invoices) - - # Clear supplier assignment for all fuzzy matches - for invoice in fuzzy_invoices: - invoice.supplier_id = None - invoice.supplier_match_type = None - - await db.commit() - - # Re-run matching in background - background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) - - return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."} diff --git a/backend/api/support.py b/backend/api/support.py deleted file mode 100644 index 40f6e8c..0000000 --- a/backend/api/support.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -Support Request API - -Handles user support requests with page screenshots. -""" -import base64 -import logging -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession - -from database import get_db -from auth import get_current_user, require_cap -from models.user import User, Kitchen -from models.settings import KitchenSettings -from services.email_service import EmailService -from sqlalchemy import select - -router = APIRouter() -logger = logging.getLogger(__name__) - - -class SupportRequest(BaseModel): - """Support request payload""" - description: str - screenshot: str # Base64 encoded PNG - page_url: str - browser_info: str | None = None - - -class SupportResponse(BaseModel): - """Support request response""" - success: bool - message: str - - -def generate_support_email_html( - user_name: str, - user_email: str, - kitchen_name: str, - description: str, - page_url: str, - browser_info: str | None, - timestamp: datetime -) -> str: - """Generate HTML email body for support request""" - return f""" - - - - - -
    -

    Support Request

    -
    -
    -
    -

    From: {user_name} ({user_email})

    -

    Kitchen: {kitchen_name}

    -

    Page URL: {page_url}

    -

    Timestamp: {timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}

    - {f'

    Browser: {browser_info}

    ' if browser_info else ''} -
    - -

    Issue Description

    -
    -

    {description.replace(chr(10), '
    ')}

    -
    - -

    A screenshot of the page is attached to this email.

    -
    - - - """ - - -@router.post("/support/request", response_model=SupportResponse) -async def submit_support_request( - request: SupportRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Submit a support request with page screenshot. - - The screenshot is sent as an email attachment to the configured support email. - """ - # Get kitchen settings - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - if not settings: - raise HTTPException(status_code=404, detail="Kitchen settings not found") - - # Check if support email is configured - if not settings.support_email: - raise HTTPException( - status_code=400, - detail="Support email not configured. Please contact your administrator." - ) - - # Check if SMTP is configured - if not settings.smtp_host or not settings.smtp_from_email: - raise HTTPException( - status_code=400, - detail="Email settings not configured. Please contact your administrator." - ) - - # Fetch kitchen name explicitly to avoid lazy loading issues - kitchen_result = await db.execute( - select(Kitchen).where(Kitchen.id == current_user.kitchen_id) - ) - kitchen = kitchen_result.scalar_one_or_none() - kitchen_name = kitchen.name if kitchen else "Unknown Kitchen" - - try: - # Decode screenshot from base64 - # Remove data URL prefix if present - screenshot_data = request.screenshot - if screenshot_data.startswith('data:'): - screenshot_data = screenshot_data.split(',', 1)[1] - - screenshot_bytes = base64.b64decode(screenshot_data) - - # Generate email - timestamp = datetime.utcnow() - html_body = generate_support_email_html( - user_name=current_user.name, - user_email=current_user.email, - kitchen_name=kitchen_name, - description=request.description, - page_url=request.page_url, - browser_info=request.browser_info, - timestamp=timestamp - ) - - # Create email subject - subject = f"Support Request from {current_user.name} - {kitchen_name}" - - # Send email with screenshot attachment - email_service = EmailService(settings) - filename = f"screenshot_{timestamp.strftime('%Y%m%d_%H%M%S')}.png" - - success = email_service.send_email( - to_email=settings.support_email, - subject=subject, - html_body=html_body, - attachments=[(filename, screenshot_bytes)] - ) - - if success: - logger.info(f"Support request sent from {current_user.name} to {settings.support_email}") - return SupportResponse( - success=True, - message="Support request sent successfully. We'll get back to you soon." - ) - else: - logger.error(f"Failed to send support request email") - raise HTTPException( - status_code=500, - detail="Failed to send support request. Please try again later." - ) - - except base64.binascii.Error: - raise HTTPException(status_code=400, detail="Invalid screenshot data") - except Exception as e: - logger.error(f"Support request error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/support/enabled") -async def check_support_enabled( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """ - Check if support requests are enabled (support email configured). - Returns whether the support button should be shown. - """ - result = await db.execute( - select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) - ) - settings = result.scalar_one_or_none() - - enabled = bool( - settings and - settings.support_email and - settings.smtp_host and - settings.smtp_from_email - ) - - return {"enabled": enabled} diff --git a/backend/migrations/__pycache__/__init__.cpython-313.pyc b/backend/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index aebd520b6bf571dc77def39084f78aebfb5ff77e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 119 zcmey&%ge<81olapnIb^?F^B^Lj8MjB79e9PLpp;dqu)w~A|@d3Gf3)|SZ-!|QDRAE veqM1*e0*kJW=VX!UP0w84x8Nkl+v73yCPPgGLSLFAjU^#Mn=XWW*`dyCeIiQ diff --git a/backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc b/backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc deleted file mode 100644 index 276727efb31ab03f8d8a28d999f9294c0b1248f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2202 zcmbVNO>7fK6n?Y2{@?sGp=o0NCV>_#T(==2l;9ZIPAUZlBiV_Blt|WMkIf2u?e1jVKs?b@^R&71c- z^WNL_h6Xp_^Y{L|Oa*{H%!T#fy7H|5g41P4f(l8JQ%GbJZ043RC*CRskc*?$KvKEts-$pP#FiU&Vd-%s#$SO-b65nhs0UNUvS_26Wi9cRMcbIIR6C5M%yxjJ{r^Zxn96UsZHMKB>3@<}xG)#L zv`MCQ!zT+RougbM^wj;|B>8<+LzZNrdI|^mEb+O^!SI`E!7P~5vYtN0l$6GF9rIqJ zx^yzn0_A;JC5l-Hm1?3J)kLe+RIrLK5D1i`;o*_N1RV)Sh6i8P7#qbgkT+nJDGuw4}3|WO-+_Qt9+XMnBa~ z%cT;ARQA>w>M_tWl4=IqN6Y)dX7~7)c+pCslxNsiI(=@#o-gKdOf>{q$re?!Xbsy* zhONPHuql?Fz@}o>yWtv~O0$yLtPCFe5_XN^85DEW4EuN_f08$ZbWu?Z!ZcHeQfln2 z`>2g$Z%e(pw*^YmMuR$`r0U$NnL}pYYj^PV7|y{X!Tovc)7VmT|2-k}(0%+|bWL#D z`X97*%m%*>&W0|9ZjP+Bw9Wc2`mgCXqjS^CExr}ew1#F(0zWYMxNh?x+6@Ab6%I<9FtI+eOb`alQ2f^0A>e+ftyV*TAd1L14%-q>! z&-+W=(M9d&?%R_;&CJi-KD*pKS~qfNMeMsH_B}$Q@eA>#?FVm>@3f_@hgZbl9WnTP z&4mj8(yn7v=i3Jkk-3gYFZ@6vZT25|M7MTCw(;|AB+_o5f2YI5A9|2Hf0#tP!hBFb zyZ~(|w}3o|+X?b4cq@3DJ<{V?=pvDK#f4p>i9aw^F6<{Je@_MXx(9ms+YoLV=;jxj zY`C$wrQO8-3huVy_F^xObiqeUev=L}&e{_%0cm1FvFHSf@fA{AIFrJ5g|%B>qb zX2Q3yj%!fOud(3_7K)jPJPWCvxWvv0+Bm+}ctXe%P9Va%8%Wb{(D*xS#s5QatU&8s zXuY!MhX1PnF6>!#HLtk5cU|5qA1%9fpR@mIZzf09-UC7WqP)y^tO}S4=VKQezG=EI M&@~{*m*Psi*t?`HM@F}^JF~l^ot;_F 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%0A_n{2cx?f z-4g&8?>&&1n~1)5Fic;M1gJa_qVi(_>NUyd2BMd_KrWQJ97s_)9mo62TLYNo@+ePq z4=eAZ_Lm>zbH}2W#{;>8sml+isQh>o@7-9j*uY<26pPMS-qtiayYD#gq;O9Mb_N$V zq1$dcK`Rz<+f*##nA&Vp?D@I`3QyZToFFekC#SLHOwPaPm)1e)Ht((Z7g)5Z=yS R@A`FExDn;T+YJd^{x_Yc0_*?) diff --git a/backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc b/backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc deleted file mode 100644 index c9e514099be2b8a88e64ea6300b569c54a4d096f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1931 zcmah}&2Jk;6o0d8uRpRICk`P|(x%fw)tH7B1QAI6K$g9Lo5ZcWPScV|tBpNzS6Z*b z?z$vVWL0np6;g;$6%Iw}p%*yu7l72<5rQJ56-W_ONWCR8QZ5{LYiI4|OBs7+e)HzN zd7u00k`x6TZwQwDQvl#^@8A!97u0hI9)JQCC`94Lh(&Pc$GB^}#Z!+K7zfX@a+FkupM!4_)^OnX`yL*aZ+{XK&b+VcjP zB)N#s-`;(#CO63q?~;oX0Mm^Od4!zQ=Jt`o`+Df{wgr4%f8TE#Jx*KDm!xpL_ZZ8B zHZRr){ziABo#FOIghGjgA^SbfFL9=EcASP0Xz*cGHlrT~5n6G^gI=NMv`;kM(an--v3Wx)urf29a|Z;I*>pq9 zS)A94D0a|*7mTPNi*E)BFxpg$(A3JzQ@Y3HgW3{%^d~O0j!SQ0IMLW6%sxDtOSf9` zen6Asc{xY(w{OV%qMuBs>CNQ$qM+hEa+NHze zPc_40T5*xG1>JURs+;@l^s<`>Sy+$Y24@*I8aKoiSOL3|g)IVH!7gZqX1RhAu4GkBwIyQ3 z@Rbgn_IJ2EhkNiW*1NiVe|fd`MeWf`n|*`p%WKPz(_0XJO{_!stb|K{=MIG9oh!mt z9OC`!C)ZAXb!tWUC)WFi7+)RxeC+4`iKk+AGyd}W$lAz5XA8KqU3K7SH!eN#9pI!m z(xmvm0!Tegf!n`{A3gJ>C!dNpcg{oC&}!{|Z99Ofwof4Y&glkewYF0G&n}_4pAMuh zl7}QE3E%LD9u1{>`5#CsCj6L`5Izw}s#|y>UG(tf4&>MfkyKA$BP@70Mv%VI-B^2w zN9h$(BcY8!k{S{>PKX{J4k0}ebwjFJtQ1t$4SA`-Vs@kU$A)GU?y&M=GkM{n=4i8; z&6)|0l$bN#Ok-G8y>CcWEw5=eBK|GZ5rWl;iyOXNt`w^VyK4305t|6u=W*HM2_f4Y nM*?*LNbFA-#Q$cb>-WgOFOh+--~Bc6>OG+@^pPoI^&$WNX@0lr diff --git a/backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc b/backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc deleted file mode 100644 index efd437c64f7c24f0d1cc89361218ec0fb134a1c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1787 zcmahKT~8ZFaQ5ze=PxiNA5tiS1)>V8QOZMTC7M>U;ZTf?Nqu%g1k&m7t({ZsJ9>9+ z!HV1}eQ1g*p{nIAmGas@(H4F|q*94m?Nguf26ZJ~`p~&EcFafGwRd)Ac4l_xYp>PU z7X}8~%iA`|j)7%1Y^VH)BX<;E?2M`Yofi12| zia4_jpiKg-2FF2>rb*irg;kOpY~#If99G3~m_A|kNPF7BS9F;xbMa99et6L++nQ@w z<_t~fI$g81N6b;zZA-5eUA3qcA2RA{YZXRImQ6j{Fv~RQi_uTnnq#<(o`}G77-FSZapnq_?@>S*8 ztwRWpMOzRa?n4mkKZbCqA9)C_LhMpo<{+xKcy2h{0xlLg1}+pi^uXUDg!*6f-`WpO zACJPo$aZ6^am1nO(N)|%zUH&G8&4bmI3>KplcD4Uc}9{%{GLbb`A9Ov|45R&_|q6- zyObmY;_g_?!>1xBu=_qqO2Y1K5%E2qAb&65Ly35tkeH;>UW_CMqk98U4CgY>l3+}X?l-iH)J#PY{=@Dy6-J5TGjOmbJ}&Rw~DITlr(44EE-lb?0j6& zDp-}(&31Z%x`ywt<}g2ne!RWIy>3xeJBS`PdAnx*04+qp_P$2V;O#2D;}!OyeFaG` zqfQFJAx{W7;y5C-L?E$0;7$DhB@U2x58nbI`n3KFf9*FR^dkJuzA$wlM7~TvNq-}K W8`>9!eD>(C0zH&K7_u*;wSNIK&xw-|g|XjzGqW?ZGxK}1 z!!u_xfb{3j-vu`mfIp-#ChdS3ZXxp+Y!F}*TPYDi@Kj66iYinpby`VVNr@ElsXPe1 zX4v}T8i0Elpk~g1Z7h)eSQKica&G*BqjLso`V1@_D2`c6Z}UiQC=I2M?tYTF6*Pn! z2O(dimhV$PV6iJYPQdFS^}=Sm#c3SU4dH&uB4<;Ce%p&3&-LyyS`P)i#~RWhaQPd^ zx|~MsAa*x`W)OGgGcRNwZL>hJL+gjnE7;4E zbW|UKBC+AF3UH4+R?DdzaJU1N)JYG3b@`SN*<{>>@mWMfj>n*|o<6iz##V^Q=(li? zQBgVR)c_O9OSoomrKjO4jBF77{p?K00RlN9A8V~E6sE9V3JrKyoq{^Krq)S8Eq6=S zYIV6ntJc!$^69;|Zr-FfZm-_CRi?!cY58`QF0U2ss{LO8tIKQE?weO6vdlj<(EKTY z7g9pr*d(>V5Uj*t_nFs@nZP!qa#I9cLaBKn=Mk0xG?{CSZ&6fpecw5_F2`ffZ*~%6 z-;9YYddU_?AK~@_K8N2XFZ3U5J?Qto?ma!fGjXop{Hpont)1M*LsfY#GX!Nay9Y`- zyDPW9bx2Pyn=)Qa?*1Kc`#QjlmUS&F2PJbCQ3@Kz(95PRk9r z<8)uIv^fuWgC3T@NdKq&?kk6bhGW&*Y_gb57czAN^lZp22YjCv1QxfyD)qmq&YB8`{_U-s_ac zkj-V;)Fs3$X1}<^%_Yl{{SON=7|bnW%A#vDG1*W4hStPo+YdXh^tIT6F`7j$yx#N9 zm(MxpemnQ2ii>#!?YQ?s=pImiYAIrknk z1Pu<0*a2|-U??byYA7nT5vSizCgT1eRf$4XH6#TU;){mk5lJCxluU+H-w7%SS(z$M zgelL1nbO8LkU&(XK2cExF%}Zya=49*dIvNvp{G?UOJX>y0?@=c1-rF4gOJ<4O3}WQQ!{`Kq(0lk2eizWG-k7)mL);b}Q}3UO z&Ffmv6t`8^*88Vo7wcNE7t?<&nR#*>@<%d=%*gW#Jl|ELvGggRjjRcYLkLN*zsHL8 zksi&?i`S;}a=pH5-vpMw5}{%Cc`QXcT0gg3E{`h6L&8q%Nzn`yzyZ|3RG|Qd=c9O zDCs&jFgobTmKnVgk$0Tlu3o3NuBEBTQDB&;+OL&9>MyigtA2?RF)UNjKLv#>q^L^5 z?vF!hfLcT|^)Ydvu>5629e4czYyG*|M+2llf}sH@vKIxqqSy|JmOz-MkQ3s7>oZ6ew{w+{;ZykKrm-am-j zGvexjm9FP@u)tqpuf9ez!RH}lk~@^W7;PxuG((43)~=~ZZi2%7C269ksV@$9Hn@2J zGbV>5HED63_R*MjQ%o|7Dkr&c^c0mHh9v8YN>UP2MXQm7B&#F(l~NfO{eB@6v3_lA zj7=p?YbsRM3ZeEWJHYrpdLP}fZJ#-F{>;qur_)!qt+G~2+3nJ@&+9MNUshKQ%$98_ zh9e~@#BH@bMqIINRr`5lMa8AdrOhkWgRAye&EoKi^~mE0M)ut^)90rj7$M%j9+e<_ zc?z+RoUQ8X<|`+!wtmz8W&736o3=NWTY7$OUOaid_1E^L_UoNDTi(56-@aVdcgx=Y zo4x;@efvMF7BKw#f&B%LZmdUo`0|mC-Leneun*<3dmLRGv6<;})Bh^1P&dw2^zryI z?%HSkf&uCZb~iC!RdzKRzQcV~u{udhgUJJJX zsJ$%rV~3`?$Y7usSq=n?7EN{0iXpH?8}Hu7EtWeucP+QHTT@+vD1ol!K(Mq=Q(bDn z5b;uzvCm{$YQ^p&)}=#Mpnqaaz)zY5AtHt(K}fvrjZ2dD;g#Kj84$V)Y?H|_KWaIC@M64n56f%=3f#c zR1jpdc9t-5T>2KJfJAbM#xS=>qW*Z8cFN`O3iFJVKKNN>FvbrIX3VBa5w_h$JK_JK zu^pGKPGiJcKS|tV%Ku<3%lv^Aw)r+&^l|^${)?v1EGuk9nqBofOIA&Yt&n#B+)*=9X<*vz4#VmbP@+($qjm8kmzzMXhAHu^X(VZn0fx z^Hi~i4K#_54RsHDU@8*Z8-KuFNcjUg%GAzd(j@l6TUaOYz#ew(#7!5N*lu!t&b{|{ zzK^dr8XFscj2{MPl&cKD6KmlNy2@phRo0p}O}ByO8~3D{fl&Z)H^4aXJ8Ni%BT~No&{JJH@)+_ck3#OJ z)_y94Oxlp=3_{(qnpadg=ZIU>L*}PsZVb5q-UMt#f>h#rnmXiCQ9F!TD0}b(Q9o+d z5yVlNK2HH$M7O9E!-DF{x_FYc`Y~qNv_}>kpB);9f9k+6=5^4#-{ERB%{Y=6vSsjG9CtU$HeRNjN7K zWK|M1LB1%fhK>z!q9BVUVzB{}5#zaNhQpcYP?E#(6F7AygSpXoI+K1$veOo@gcJ#< zIX)gu;jKUU7PtU7BlC0-6WyVmZ zRh;2QGc`LoO(tGQ!o>V$7+bub)n%>Dbw@xna3(&&r8Ciyvv`<05zQtuIF{u}+! zq-JP##tK&w{+pbg=8*j?PFBkoPYrXUb>&l}Iiarjf>Rkgy=PMOCGK?26{!o?3K1%f z{!crWenAt}yo_IAWsBxiabK*G=6YW$jam%;+p(fb@|>&}17d+Fkmj*Gr|5=W+!L!d zd?f%KPbqm#w8}exLk*hstuUDhnOwi7hbYL+8nQ;NN-*7p>3m++Oj=PVr%fL>my_+T zFe%;8Or|h>i5z+^iJGdY`4D5Wm2wOA3uSt${+M)bT2+b6g@TD>-MVzGHwK#>n||oy zq9oOq=HSe{*{IE`!X`|!J~KWt^>y-i3JXwnvpc;D$#OgRE}(VqM46$Q+zThm4Sr_e z@s8HT!JC7Nk*^}RGwaQ}7Q1eAePi5Ct_F{+Hiy^P@CqA#%r;+3TuH3#KK6+1d(zT= zom$`3wph4PxYPX|wbZ}5>%iK!0}r*cf;?DrEZ)^a?46OV2+&Oyt!cy+JT&-oUlMmB31C_q~tq z#a3GS*1Y{cdi!6LJw)O!eTf8j*)tqOS6gGvu!M#VyYEnhyW2k0N8M-8P}qIneVp(m zgoe7kCD%TS5BdqD^e!6erAzP87Jmp49i_v_;={_2E>$j*bk6yZ&;AWVshDhMIJ=@IpLHK$CQ z9;;_aJD3fm-eQ5=dQK%5ZCC1H1*FE1O(N`DbWsyHPJf@XbB?JAoTsr>h_ zLDryIcfaW!oSK$q3vxsYk|nvUK_4cMGKCQO1p?&%E9@cv-+bHFd~FYXZD04T`rcY_ o|KaXH1Lac)X!i5sD%HA9`#(!uPTXkxvgs!pmnopzw05HCZ|J};Bme*a diff --git a/backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc b/backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc deleted file mode 100644 index 3ada1676fda26f31ee130325d7c8c93bfff620d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1469 zcma)6QE%H+6h7D2juR&(rP~mMX}jG(YBW@VM440uQqp)RP3B5*DphJFoJgX*r#PMde9Ula?o;*x~yf)Cul39hX~^qDM+bc^CI^6_0Qk z7`AD1`o1Pi;S`)4nn0Q8u!Bv%Meb8g%76>&P?M&vQ?MC*Wwdk>w<^|U9Lv9Ru>Z~> z#IdnHokPd0IxJ130ah{joPc#s6m#$Cew8t#|o;SCr?BbCOdOhN`0Cc!rL ztRXPCh<#(AU3rgQga)}v8ze7k-A@YDx>{rPLb0lz9yKeJN^E7Vx_(1rv=Y5Xg*9hLoOHhCs9Fr$AHb!AK+_d*;OHo#**HO~dSk ze($e2kd&X5$pca(iEk;QhZl+|`XedQ z#82~x4jCy*iNkp@!mp>%;qXmTl*PlhUPOFE3EGdOk(5bfNhOL*KFX5fT;^yt6XEkR z+QXz_v`xn~jC?AT4a4>7@7^rUTe%xyE^qrr@N7o~8` zHpe*X@3}v~5UU42YH66f*7oeZ7JuJAhmX;Sf~zP7ln{bvC*m*(B>OwOioZV-3b{7O hftb11{e`~rSWJIjxl{RC{wDQE%nc}rIsYui{sS_zT73Wj diff --git a/backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc b/backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc deleted file mode 100644 index 55e5e3fff70fefc9e8b26f2eb3fe451af9dcff7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6725 zcmcgx-A^0Y6~EV>@fRN+j4_0WF!^ZLP6!DQ$cB)t92mgZIH_%RlZAFP*aNZFjEQHQ zf*YY#`q15N)hMb~+lQ@`-9+8=ZU2KR-w>$kCbUVdR9dM|`^E;OMB6^}T-#$C7(TMu z?f^4q&YW}Z@1A?^IrmXd3L4vS&K8SCaKNTEP<-w|BZfVw zJj-77rALYkB>zq zk(b4o7|w}U1Sz5+CQ4vNlvza>jfrwI>yBtiO$bwbM3@G#IQw2y37;1wu&pRjXNoW@uLIJuUTp)&xDjCG8H`S*HMz+)ni-yewX`L!X#T}bUDpaX$*|w_E>T^@u zj4B1*vwbJ;RsZ1Lv#XfZ=cYZZQKg`V*`M}&%zAWdqiP(PP#^Y(viBpq(Qr2NkVrEk z;Rr$!6yyy8>M6vu%jRH4#{rHY6e0g;5=7+U4UJUxcT&I8W0LPzK5u<)%5AZ4SYJg8#STq422q>s~#p)j-Xs;uLxA@ zF&&EXoSJU?HK>c_CF&M&+V701G^xw-?KJH)Xe_bDI8dU{#^=@84E^#nt~_0?5o zo$K_8xoCt9asiLq$DRpz2Hk;ktd~32q$#RaTTt=%LtGyh$hPPo^7+^R*UJSse>WFY zi?ZyDMqJv4qSD29G%WI47|M%nVn|j5B`LFSy93?*?truPu&aouhwF6@`9ka)?(UH1 zZSD`8;O!f z)rz37AnWT19*uxPHvU1_u|_yQbyAK}>Eg5l4O?ELBwA9nrP8GzcIGpU$KS)9 zU6W~2wd`rXMjxFS>`F03k42@3IK`$+2U$;!+Oc8(1s$wsR=J*Js#nQ&YquP)pZrQU zIa?*J^}8aH3yGV0I3EWyvfCZ(cK2}F6EeG7iYns7R?QRyH<_2YT^CG~3rRszqRMnJ z5gl%7+bR(yrKrMBCLmXrs%@FS3clJR`>h+|HS?{d7(MG;x^9HCQz~^zW4dXJZXA97 z*N#4QC;QqZx8Os*u#i|s~Taq4ec2J&K?L>%e+^tP*yFcD8mN-?^&Q5~Z) zpU+M4u`$^Bv!_m<_Hk}9xpEc1*X;{#Ft1o5-yrgu*rHNumuAN2^B|q6A-69C zi^JTyR1GZkjD~gh^swEheM5s9Rf8{_?x-tFbx@BTPIVk0M?#`T@hz%p915kFNOCe3 z4d>3QV#x_9)urX*Let?m5!%?xrUW^p%h01DoGfLG;*=OpDq;dw=)i<*DJdBGscGE9;%k*A_Zi{w5?;Y{OB@6?X~=^eAfs4OBQoQ{ZmK%C$X!s8%Lfx zTE29CS)V>0NC$`0fsypcPt*3FE$emrY|BVjTm1s*s;ZaBZN-499O*je&AOYGw4?1G z%#m-Z!RFE)W)&1Ofnp|5%mj*=K!MweJqv|fEbm=nY^yZNh(;OFC?gtWL<4RsbyblFPic(oNeUSWlfS6HFr z#ES!q<=z)Vdc^FyGV|-1=LYca%atD^X2&w3z=*Z+M%%Ru*E_!G`mF2v$;Z}~XG~4n z;eEpR9x=YJn414AnL+H^=k?&qmn$Y@ZvZ_Qi0ZvJH7XBcF$wG=PUaTVxXe_+vf~;^W}bKAf zJYj;5m|%%s-D?8|ACew#=&5vj-$L8$h53%ZcFlFopM2bYF6+C{6K3cUGgO4~;+>rL zugrWn^I!Q6Y_2cY4_fi3m^*CvgC3}B*w?QAyn*Y`{TXv@hFi!A$LX zj^o~=rdwS$pl?@Fz`tGH(686sK8*X3;dYA^=sUQDh~(T`80d$H z2L3~uNIo=S=-0zCi?`DFu%^jtHqK+x`n(Af+Drp~zLH4JS7GSsyxrnEM9=SX)4u)m z!d_x@!G#IEpN0<$hluDxD+U)Vv>OJ?jSI)I?_~KxS2@ssF&TlMG4YVq}B|HqR0WByID2q7rx+eIhCS2`vMO5^xrYGN!JHcqADUPbPN5E5sd= zy>MI7V~oGi=`pozLfHB*v=e@x8zwP!ERE?ARsKQhv3|!tDRbJ=zDRXErOJNgz3jbe X{GEA`s?V|;|4y+>Mnu&oSg`j$fy^|L diff --git a/backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc b/backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc deleted file mode 100644 index 25e409b1bc9745f5b37de8a2118fd0d974b0fb05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6009 zcmd@YTWs6b^RoiMJ*=q|M;AoMSiO7^n zQb}wS#*lq%3swZhQ0%7zvb8`lV86pYx74rw3`kb5>4Xj#u%BgL%4{IPHVivg5-G`+ zZ7ExT4x>%p!=v-M=bm%!x!i7PaU;0afANl7@FDbP`eIzRGO&FMfcH=WsVIRHmT|0N z7_H-$SFNg*P@HYt_9~+?05k1Kwa+>d_JKDL68s3wIy+Iq(T7X22%5!l(6jsMM6>oz z)K^AVQB!VbwhXuBMmF-%UG-Uw~gr3bc!$Pz#Xjvc6Dd zxmGQg%BdFqdP%M-U6YiYMD&aQ344?Pw>W8T zZ_}k4I*fKnNy&XON z%61u44aq)YP#fccvONYBBy{nwue?rC%WhAmkQ2pz3-`}YqnTnUp`LXhp%)g=1RrE`kC2KRlrOe z{#w_Dt!HL~yN)0JRo8~U?6Tp~f3OxuYu2J=uMML(cC4NifIoT~1yM0(Aooi->_i>} zyF&32p^DF22(voC{BJWGeiP364Y~B=*`A;ogWQ zrV_iWHSzCZrm|#_B)E7qJV7qUqm$wIn`E4OGgz1Os;nm$B!vf-Xl#lb<>EzkvFV8k z66YdZoQn-}2~sB0{IVFRFUZ(Ic#-ZoBXk1PZYrogLwRrcW;i}P7LNNvJ%K|QObx|N zP}^f~eKi?XhH@>Wd1Hq%_zoe@=yL~CCX0Kh$j>bqbkz>aX_9(?w+v?3uco4tTw*Fb zd6|rGk?`~c`0TaU{rh-1m!HknXHR8(4v zFHlYyz0|YIDRcVG<&-|hH^nfY$%uTZy^2jwLTMWIzt|HD?GxmpDl1pD#_7#Ti0Tq= zG=vT4DYcZkpz4j(tEWxbI@wdRt_^2TXC-^g85@$casu_%>%ncANG3zRs` zt!(k^VD1llBPg_Qq?R~gt7wKPmE|GkQj!jvV<{K<_Eqq-LHA!Q^%Y;%R&&~D)MA!j zs1u>PVx)KkUGA?6(|QE^*Y{sG8LRb9fB&=}|W< zOS#ck|C7ZbMLghC>6ffLGbcg2n|DYzq-0K)RQRp{$Smxrdfvs|NJ?4yF)44=bT!YU zGuI_GV9DE(8AZusNrU7&`Ca9VHS;rN>-)Yxm%w^ALR- zeeOMe`|VqA-(LB_%AGG2Y;CT#&wOn^3ceeBUoRl*~$Y%?4$ZFycUu*Yh2c&H8!)#0H!JX8lfcG`t7TDu89Ice2m2$LF zj#kP6kDXIRj{YsrS0Cprh&^?C<<`n0JJ|bVrxUR!wvi3QcsoAqz4O)wFMTxllfe&$ zKJmWzIeYwG+t>p(dY_H{i9P<0f(yw0{)laeZ~Or}ai5*2<^$WG>^y&nzRM5TEBDze z)qF66C*atF?VNnT#(u}fDtwQxmu7l<<@+oD)IzYRH`~X2_cWwb zebw!y@QEWJaP8os@F@1x#T-1NikWO1a*HLHl~h9l6M?5BekLnT<1)awC8#e4YXP z#zjiG5yD{A#wGV?h}jtOk9IMe=j?#r3}Om*F@WC;QOeCe4AyS;+sC|)%^^I7J)2+m z0Q{RH4#4M~JijQ&3ePW}i{}&t4kpA*sR2?arIwGGA9~@8znD!+x^z0=&O3Ns%p`d} z?+~;lB`Ig}ZtbnKkcKn+;!-KOop2%)<^(uz(gIM-N2*^D7A2lnoq)?*)tvG(v<)Cq zTPeahxR?=hX=zA30k5#eNE?So!HO|{WN~8Kwhv+NAJKF0|2O-$Fm5YEtjOm1-ts5b r6MwL|?zux-w%*TdN4`6LbNpS$4_#Zf_9D9D_cl^+AX~foWsvu8?-PlC diff --git a/backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc b/backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc deleted file mode 100644 index 2c54449921252b7fbe743c99e21c753c46c74f82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4045 zcmc&%%WoS+7@zU3*N)@ZO$gA0(oTv>;>L>9KxrEqDmQi%C$0@%TRak2w)Q4nb-inL z*L_%3K;=MakwOI(LITl4FWmYU@M`~n0I4V|kdWZQ0m&suy>Q^0^&`894OK6!J-ajC zeDj-czSqob^z;M}JYUUxC|?dE^eb&NAGT51xCE7}h(|i&G4GkfI)*PZ=b2@6hEN(i z$Ig0nFVww*Naq%OJU8(YLgENQ3;w-`_Z`G_EP@trwy!zC=65ez;P#?}4T6K4j`nZ1 zmpn_JNLTf{zzKOt7fo4HCrGSVBt^+6=yI771W}PiLo&!LpGlK~rc_EQF*PEqOS)8) zCDjxRt)drz!xT>{(pZ2uH62=_VoJISpl2i}9Q4CU&4Fe`6WnC*)3MPmGQW8xi(mB~MX^$eS0AxxfV=VAHU3)r$E!LFI znegQ#bqV?ln@U2+2aUI(Y)pF)dIAx!%6zLo3$(4zrcN3fUKLdd-I+U;<=|<$OL7&9sLd;{Lgi_Jg%d3OrB{g9h+x6NXLS7T^*$hG-rYs zvVR8~)=YY~YJ3U#QTi7sUqQJFWd=&m5Q-w37oxUqVLu8Y2tfNOLi{ay2-*WaL(meM zWcHv%e27`Zk?wT$%>Z#cu~aURCArw~RKnrq&7g6!#^Yovlggh+ljJl>XL2O*Qj*W{ z&iHZ1V>~e(%cpW=c>dMl7F6}{D2-yNeH{N=2#bG!np!w?5&7riv0TCqEx`^gsJa;V zM6MZ<0u4zrNesO()`(J@6m<=tsE*_z7h%LyCYDO@Q;88p9&`0YZ7>%p;zFCwW==Ta znz;yMhywkLCezFW71yX2XA)UzjWc8R^!BVas~{I$jD*C}ahuVol*@`N>A=xgqJz?5 z%xT#yoRZYdA)ACYBetrIBzfBh()m>Ce%Oa5$?&EVP&L=3r?}39UKUN7nePL>-Kb8p zP}4b$Zo`A*B$EY~ad%Nc-Da#yn##PG$d2T)$rB?6DBBduqHa=Phagds7+z3^qvSv& zvK51_c#=3#+ArNPP^WpJ`uHe0-q>g$YFx{J4W_!!v9*wqMO`a_OEkB?#(rf)s?(z+ ztvOj$Nup8c!fS45opHoJVOMb({6T{7w5pv|$7n>lpzL@VagnIj_(Uo(l_PP;Hpw(p zV5=XY;o&6OfpT^(n>K@YmS^U4ymj|QX!tg zL|q(qo#QXxpRy_V18BUxl5H?5CS=MuWObL~BE)t~cW!p;ww0W1gh^O&XGJ!$^SIMd zAqCW(-{HJ28S0AsUlLYJww9x|Bx{#t4EZEDq4Q)LsUCfREYrX-A`wof*AUBlQi9X4 z<&(}yg$nEjka2)m)@9YS{JM6wpjA{e;T|>-Ja04;Bdf*if-9t=Q2xwiQzx=2xv=*9K_a|#1v~$--7p`2m90dC;&&w?KZujgv0e-z;Qp9Hx60f6g&D750M?MHnBKlBcL+dH(v-RbUq zKX!Sd0r=#Nooh#4Tk8{U2Vb`VpI;BYa4+UTJ9e)wUs}HF0m<(^0z%z=2B7X`u-&n` zyt4eaaUQgBaWFoFKfzOz+@}oGuI-Ndm@n|uG49JK)V?0Yam;-kjfJ4`%><4!+&9l2 zfcnj7H*nuPgyUZJ=3$Q3k71zS!uFf7-(C##TRtwn+jpx6$9Dv8g@Uv`=mWat7le{1 ztAbG7pRK4W^}UuwCrAg2Rv&6zA0eBT%8F!4Ly>^x6NI8x5CqF78t2u5tXTo$4MkMo zOjJ5wcR7Digj*xrFKxH9-GR!W!j(`EbU*0AV)TmoIog0K(&0*tEdS9G+yE8nIeieC z;5W!f!K=n#jPH6_%x(k_4*!fEga4mg4C5@W&3F(ST&aG~4E)G;tp%Q0XAj+ByWg3+ aICsVOLDxDvXp{HcW=YM5*g<^&0RI6p=K~}F diff --git a/backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc deleted file mode 100644 index 556a92bd28b6a1a9926470382fe372a0cff63231..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16184 zcmdU0ZBQFWn(o$&MnY;325dBZh~dK+43WVg5TCYeO719%bj zqA=>ky}Erkj9IA*>!9TJagXz1UZQRMKKV93QYI{MF{TkzfucqJPN{grdcP?#vC=3JI$%bMqsspO!Pnsdog zdaijc8x$r=sX3P%rRSRGvQc58l$vwdr1V_#TttP5QfkhnKY@oQyN9ds)`EkDiuWq z_a>(0J$AjIQPD5fm7*3^MGvG=G&_Z&&!{ST6tooFt?X*mq^RJwuPa4!RTceN8b$L` zDEcp|ioVLU{2{v@)2Qh1x>7V>Rne(5ids`BI;X1WWv1ot+4Y|^D*D@XrD&mwq6YV- zG>R6js;I%;sG_LBT?Sej+#FN%ibh56tt&-KR24m%Mo}q+q9;`qm6@V@*|kEGq6T;C zx>B@URnb?|C|Z$1(f?3Y^c2(bd3GJrsOS&Zm7-fz6}_BB(W(@R{*S7nUob7-W!Hb! zsOaz4m7+B&iW=QzX%wwpRZ*jRr;4IRw;i-Jx-Cr6|I(zW(ao(ZMYpLcDyLDjE`_2$ zRaKOJ{xrIK*tJfhqI=hsq7ABwo=T%=V+uvzR8^FI{xrH@WY>sBMW0_+iZ-h%`b8Q= zovSLE;m%N1lz#p+x-YWp?=&j^!b?I{%9pR8yzH;Ah{$Ln0MTfVTN z92hwsj7Z*~Pl|e<3ecVLQ23#Zbo=-=*ryKCt>}mL*nM8;=#9wIab;INVz=Af<6B+( z`?`9hKG&}OU9c(7Hs2$XYtJ5O_rd*#54aaM;`_S3+c$0lJ~A+3Tk``U%8BmjE4{|E zyn(Rn^^HmLv;IglGM?=lIT7#=c%w3GiBs$G!Uy^$nP;tRY?a2F(oyrlzB+8?vts~K zU-yBo-aglXC&o7~(0rg(g>9LdK@z2nDmK9E&^Kqcd@e!icK3BX3R?Gc?d|I6a_{cy zmFSU$NEMt}7=I+0^oouuZ-?A5?-=MOE}q5m2i>~5boBhuqPUbNV^Um+~4JLOUhvA>p9$|%r`|DW|)F@ znVMk|>y0WlKzffR(u3j9agTi5>klmH%w`$=TL5b>3$el`1?A1F*e0>Is+$3EpLXI8 z4d^A&x9nRue6@7It9L9Cm5*;3-IDH+Z_CJgek^1uk3nxqFB6B%944#+3y3DE=> ziH6}=WauPxGo18>=}m04F2Tdm%wPh`5qexuk`iT0;UoQCpU<=S?kpW8wVxPEWQIqA zo+Z05J@QJoE+dEGAntYaV>HW~{&wHr?3>JXP4l~MnN0fnnOyOu_7~b;>ioyfU-ezg z+4NHF&ud?eUd(R4!|9}~J4lynxrcO_mRs~PFQClazu))#zDcWdnzY=@KzU^^b>6a? z^v>1%JeJ1Kj%l)Ug`b^xHAe$!96dTs9#b3@ne>j;{5+Ay&z@=0rTBrwR`qj4o1d1| z`1Gam^Vl@$Ucu-8%g?HB*s(NxR_YA;w5^ucchdNIe46ZAkyq<#en!*y>7FK!D}La8 zZ(7aIp)`KFrpaE#59HOknjdc(KL@6Xdxf7}s}5*6jiW=;q~{ws8vN#t<~Jd|d0_?E zbAj)=J*_tov}lmVTZ0=CCVM~rkV2AnM3K2bkWK3UilC(SX^%py9|Mcj!sh1?S-T`|(N z6teYn{b>l<87DhpWCshm6MuC(FJ+bvO(9$2q%}rbmqKovz^5XUg$;4i7$c1=q$6FH z-+sgvO*wYNNoS07F6G!T;hKQ4(;O$x7;&;3ThitD?MM9fM{AoVRd>Y6&KTLbq^e`W zJJCB?xHzDgs%`01ZPgTUaULv1Y@9eWv3s)687D0<(!wIPrkfp2nj*HwNn4DxEk&%G z*f7CO7B!g<#3FX4i?~bMsw#^l$ixPWX2Y(C=(V z%-oqgd_*st*(g$MH$(NA7JS6WpK0YO-XVU^f?l)Yqcy^7B1dtN?Pw$SS}Q(k7hY=< z0iVq?L;cwje6*fFEAbR>7W~TL8tI`K1XqZZ3uI3JMmzf@OFy` z_?;Xx)W1`V2Rr$9Y&^x~;`jCFop$Um72fIK0KbcD{;k}*Htequ-YpR+cABC7Jr4V~ z@bB?F#TjD2j@~Q70h91vIY)7&Es)E-&*MO*@V;K8*k*?M_p5Orn}2^RPw_S}l#f0z z;$VaDK?X-Lu?5??54Pf9lkh=}2zZh=L;a)$2V3~bY@XtcVi=)GJ030)Cbw}EZ?_F^ z=042E!zIFp8$^n?nxXzf2OcitKWySDZWE)`D3*t#dLg!vqu6STW^u7595o8DW)bjI zwi)WD3USoTPZjYLmx?EI&{QiPwF^^i9L1fs(PnPC2#?ka)5RjiO=hUSfbnP}e?iAn zY!JuV&;<#P!JBl6-Vo9Fbw)jb_|o@Qa!_s!uN>b5=@c~a;c2~P8jIqNtkGW1Yy`>>fgX6M3<$X za35Durytv`fM*1XXNVq}XEx9PGkF;1$4owkc{5YMaIw(uFwB(W{z@`aK`5@H^K7Pp z2AFX$>@+>u%Kbs-vOMYFKG{H>ev)sccpJkGJv4vPN&|e-fnh3LMi{2pWsYG%@YoEO z3HDgXm*m22;fh3boPF#rvW}SFl;jU2={58%i=R|pOsUmpH*7{ z&ti&a4SHyvB{aaS1;YfLh3RYMW;Zfy6?}DuSsV71lGzeMaSfg5vvwL_wvOQj)1ZT! z?QmHJ>$t0gI=z}>rFbjDb$V#N>YxFxIx)=Ut8EzO_|;B|uVGlGN5gX>!u z-YR@wXSiO6{SD-LJ)xK_AD<%{;By^aNS%Nj( zO#^j$Gt)}(W`=9@(0sFw2DsUPVKKScgkh1n*}`zU5Xv)rsly>X`H~|PTj-+nWeyGS zWgf%%reTr$veac6&f{*?QKz?>tbpgTD4xsHL-U+S1I!g+Sa;@1Fsw&&Wejf?hC2*% zHF(%g=4uJWPP%^0wb1}`9TeY2rl^j)ZE{&69o!v}I=xe3rMQ{l4m~u_>u7*^J%%M} z-iTr8nm03?B}6L?^RUR}llhH=;!?WQ%~#L>^OX!&n?`H6`39F|w3554qfYM{trSZP zSL&hpZVe4^*N$N|yIYT8mAl)>aIcj1Yf8FqX91H0LG zpc8zzd+)(S#*iG{zwl@dg3yQO9#5El@t8d%OK|Yu>^JBRyhdUARfL4G{dmYX5|BH? z61;@(H=u|IF1I+0@tn?x`8zoXTRuive?W!s|BuKJN4YVS`|_4w*1l33Lt8FpWXCfK zVi^T5JEt-#ek|M;c5-+xzO`3CJo&rvDNek|!$q8i@qfvB BCL{m= diff --git a/backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc b/backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc deleted file mode 100644 index 63f877b15c4fc33d227f2ce953df67598d77b503..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1726 zcmbVMUuauZ82`@wlbd8|6Wg`ejIO7#pn*D&+UaT+NYg6)vs986lol>`xhL6W$xV9i zjkaM#nGYL9C}Rv?D%4GsJ^QY-eNiS@X~lV&DEhRw%nT~(gWpZ_r?$$#liYiL=R4o` z-S5xuoN8N}7jXRaSyuam2l&-lG=@+I)l~$yAPEK}Ns^l;2Em!1=B9Xqr;bdR7N$f) zL@Wlua4kwn*T@?H%5wmV?hquUVRGP$!Xim@G(OOpL$K%y!Ejx0NZYY?x7Hah!-YMi zDena>Vi*hLu}>I zhk58EO5z%;qclmwdA<+Q1fx&)!yweMLzW&?)(t*D!qtX~eM)l_`mk3NGIhI`_%sQ- z;-za|dSpar<|Yz!E;=?daf~R(mB+_vY<6b;Lj1pZH#R#vGZBr`@rhTX^D}dFE-^oG z%uz9n!Tz#-)1feO^$8>{wJTYJDe4Mk%bIDKrGQ#2WVMuHu{zC5BhNp{3H_&!L26#s z3I#k5VYlI&cU!#3kp8yBmRYK3F$0N>TKS$3|q|R-)2UbvxQV% z*KNW~hjx_Op6l_{|4_ zuAT10_krZMf#lDDu0JatH2h<~tLAd8e7*da`3{D8H5lt9>m*8DAM%JcNQ~!i_eKY} zFGw`x+5|tsP5%kp+6 zn-DbE=3TL%wCy1=pKsgib8xqWa@#HAZKvt7TzWQ9)OGA1ZI*n5{#(gQoy{u~*^(<{ znZ^3UUR#o7HJ_4YTT;vwJ*DMsuep>}vUpeKRt~!FRu#M}6_eE%QtOu!fUe}2EE{et zhs_&B{Vr4yg3;~>+@ra?TFkPD(Su7ojbKjVtHKjP9&sEIsxBb@AK?`Ke{=ascVz$s t-}TZi-}8gu+3^nT2`}CkPF$P5I(Dd#4HF4i}0E!w@sf&Gf z?`%k{C_?Z+K}ey3szoZILcH+8bDw}F`2&KWBC?8v#8clAT&W0nVAl2}bt=@AcV}*P z<~O^uGb1;;yFGy8$KlWOcN_qJun&6(ZKiPznOl$o6H+9_jS!O{K~fhg`fLTMs2ekRL(jF((Iie>Kfu|T-~a}I;>{;F_me2wKDO>bAVE@al?Ql- z+~O671O{XviE-UNCYhJ!ND=$?HceopJ21$kdsCaskYw}4Y)OlHA;q8KZ3OyauY#>> z)9qd<9C|T|!?w2R^C&O8ztgt0>GK#Vj7SPjw_~5+I^#p(nfSPD6wD=XfwF}2Yn*wU z!+w~Q?6^6b*=cak*oqm>3#Yg(gnsmDa8XXlwdgdNhO>M>OcO*K!x`~I2%6`?T4}wx z3nVzwcA{ZkPjeLdF^ecD>6Yzl{4@!=m71KnAV<^jc4AvJ6;F30`Pp`A)62v0q^;IU zZQN>CKux0bSi3|tTBDn2(`Ko-skK!nH9b0-jwfk4`sQf7vp#54mT7E!G&82q#5t;r zr)hj5kxHjJ%j!}z85@o!kDPot*wj%nqoY)gpNnQj(=^bk(m?Iv|GSZs;}_76l1e9| z2_?;Zwf$7Ds~r`qzMZe+uIh%$8bEE3Qa`<%HI2M6Lp8HhE;FW7&}gn?SQRsyH!5LT zyB15vSy(el;$kLF6N((4*mj?4es;SfL$xY&TW$G@o^lq+H zMa~_c&FN*<+k$*u!223?(bTPKp;9Nh#cU|0b$`+Bn${GyLzVf#_E*lfTIq@MT;17H z(M=!Pu(o1(F-af8d+^Bj(&GHb^NS0gEZp1u``-Px=Wot`Bd-UbkNwh-(g(d?~{*`yCl{xe#s+u?~vTXe@|k5@rN*S56+O-fcPLR zFg|iH7T}f+kl3KObWmWt{~*ejhKMYQOA)5C%y%KkvX{tSVcEBX@qWVO1I=}ipw)7~ zBfA~TuXts_u^e&W?uy_*$%>mHtf1{tveF~U0q4pdBKJxwdnLvXI8k1AscI1mKvje8 zx>HrPQchLt&a5?OaI76C9PV}hs<95#DoFsbzCrhglupe5gH;8-*2!Ve}9U7WSFdnL6C0Le&&1E agsu-q-XFQ?{LKAO=v(JO=ri$u0sIHlU-(A= diff --git a/backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc b/backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc deleted file mode 100644 index 447aed374af2567a0b8b84d3789059335432ccb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2339 zcmb6aU2hvjaMov^KR72Lp>34Xc3Y?tmyl3ONDCBXY)5tDIO1%VN{w{7*c{a@&LR96kU6zbuJ=p z_c#G#O%j15y1s;gxk_$vX`cWZ>DlR33Plot^aGsV;+(@dfirv@hQZ(04@Jwnap`89K*b@HJs;; z!5kUq=13$^do}uh3M5_oB}CgsCXq_SvNUlynaO78(2g^kt$BW$PKeV}RMD_z%1&NO zM!tl3Khh{iV~^m67ZPHkQI3)(ml-`z2Rjt(;2yzfI^L}DmRwu;@8plX-4d^zML%H8 z(!qA_XXzHT#g-D+G0{{uA<}I0{Zzv35ba%NFMm8vW7DbJRC-6Nx)c>-7oy_On{PyD zE}i@!m)N$~zN+@k9!)VZ5zQuOG96D`rpXDKo^Az4DJ)7IJSesO?;ds2=}z_!wTVP% zBT{W7n#?%&rgN#(E`wjsRP%Xe7;{xsU9wvz3QD)!>S5@m~u zfqh%iIvE=A>sSzA`DPh*Ov}#}SspvSj=c^n-pI?UtXsTVDHfS-dF||saF#b;(KL(K znNd|uY_`q?hAo0Fw_Yo|kBc#b_IL1TyF*9jm#nZ})ufh`-i?a7a2gL0JcVoUr2D|? z@{Q%ymCsl1z3}+Z;oHkMm%oZ{0DnrT1Aj7%pucMi_};GT{6-h__TN5o^T-#+uJeC) zANXD9U7h@F@~3|Bk&yYV_u%c}o5Od^4d71p)PbYVBk1eh0xsN(Gz0?l9omQIpAdxm z8cI_?3sXtgoStDCl<3tiU_vN};NA3pV?iJ_k z-r1&dvOG zcXnph?LB+qfbGW%UpkE_z+Ze4HmS?BuOssn7~p|H3}Kph1V?dNxF~ueS%gjeLNeO?CO+eoJ*)0i-P2UD zZ93+$8x6-}_86^KX{lPZ%~HiGn?9}Pya+q89C*qci zKs`wZ!L7Q?M^x)-g(0v=g*aC`;~DYOm8XTXSrw&A!P!$1!iFxkC} zw6N7*R|Yn`D~Bi;q!0uBXYAi$zl;4k_KVoxzJO9F)> z7!MTmlT+dmD3Q0s5=ln&=A4qrYdM-%&SkV6qns+K6jigC!i-MS=c%5}Q*AD7vm#X|(25YgRc(M6Y0y^iJjNf3M*&EQz*h)oDIGqZxT+W_BYK^z5ba4Fz6g(|$+%S6w>+>j=@?uL1diuSBM#uZK9D9QH9WA-Zsl8eA{q8lU^^`WZC3+qE z=F=DEzU^BtDEvX$-n@%s^W0JXSTK9X)Hxnz$i&WIohKj}@%XdXidYFN8! z6nD{Bh1f`<4Y5OekPPizhuFYglp)#Qw~o6L%K9YiJG{K~&l!lse~Iglr0m)hO4cOY z>RP-<5NvHfPP_WcOLvz3_3z=?Zx5%2$UUNt$zO}e-9M~Oir*78DgQ8n+yhF~qw<3h z&Oa20dQ5&O2K;M8Jt04Qo%1U)QBTS%QHk?|g9x(n0#Q#%D=$9B`6S`;qX9h@&=UbY z8PHRN>rBe2M0DjHqMl8xoJk=6Q!I+|R?IXPu)vvSa-bD8O}kn&%~sU%7u}*$ZN>c? z6{}Lb#ugSkQ4zB({P$q_EQp-ojyZ&DEikkby@vZOwDHW~@vCpe&MZ{zMunaA4&s!@ zkpDK4Rgn;~CWu68$AAp|0k7ciZ}|wBC95d{QsPeYH}U1i(%>i4x2HdkelhS!8eSDa J8ukvL^?yLUR&xLV diff --git a/backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc b/backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc deleted file mode 100644 index 5e1a88ad2559372208f286cf98dfc1f37a847544..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1603 zcmah}&u<$=6n?X7?|QR#-MWROX`2oqO=1y33xuBy$ZT9SvEzar3#3$9ZS9F`vEB`{ z4oadZs(?ciAz4UNa41p_T;RlC0HpZ`TBww;6bXq_Z;6bQ3kTk=y>62pGWP8I=IxvJ z-n{qCHe<0a!13$ludA{M@Q=3$2Cn_l%%ivk1>jI11tCK?K`Ca0DUplR(}gmjsW1;y zxJvTT_O2&HKU|f^NK3&Zg>Wx$r6MGyo`JMjv?|a!KNb>TI!&Qesx6YO&H2p+LsP=Qx`#27DeUCO+GFpBFl?1NvdC zjR{PW0$j&qe@bqNxo{LLC_+BmG3|g^FN!}3Bsf?gC$Uibe2^l^D}nuE8AKg!tBfIl zS-%;cA|HEf8K8m?#GufJQM}(+`$Ut%{-_ixhvzN^{sMV0cY0VxpWH`56FQl_+ZKll^ z-7;#7UdmsYrd4as<~5^UwJqH#U1v38w8bfhet0>ZPq+Bg+AvOs+AJHQsocaqXZY?Q zIfs@nw9Q8kzM_W7nNeP%Y_aOpoe}DMT%DhKdB90I6v}ol1`*ArHa`Znk#0r%jfXQuyWw#=_FbQP0Ab#3U2rs!yDqt zY>|}~>Wm||!A&?N!!)>VTTHjPZlQD~)2>t)cSBz7l7bs5*_P!>oH+|--SJ39soP(x zR=8hQ=c-XI>jx_^I=|$~EgQy<;2w|m&QJJw1UKMe{F%ne`buN<%hfw4w!2S#v+~u- z_Y*q;iM>ynqIj;W31W}B2Vz3KDeWK)5?kuPeRW_<9lWm&K8z+BnYGNlp3H-2b~~Eb zi^I{r#_G-0odm>>H_SEj_N&{`kD8)z8XXC7bR;P1j`#Ri0wvy1*VKC_-hL3dxOdXu zxxTt9V5nUYSGx%`+dJ;-J)i$MUtrRo^=dDW+eDM4??gm*`m|%>&qPzCUtU7AF{+|+ z<4vNep^dk~9)9->>hGQ=T1>io=A?&T^K3Q=(c+;^LGrLdP`{}X5665Q_wg}F8e*I5 zBf3v>UFTje{HFW>O@zR`FN_;}WkMID`1u5eXrGz$<^kk-y*+{{L2v aZYjOLDZSsm@w+l`LuyJfGR~hx)Bgdp`)RWP diff --git a/backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc deleted file mode 100644 index 083b310fefd051a6cc543705bc614a5049f52541..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4649 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 #RD00gr`cM$69w z(;fo=mGaiSdLcgkZp#ONb!?CJ%`pO!>j4= zZsm~p-9;D#y(H{#_|Sg`8-LSpm4a{o{UfmkGU3aG%c{mQ2e0YRy1U?>hali*4G8}+ zxLl`5lKdo1P15kqKQi+TLjNX_5C4CMP_GbKAtLLsvY%A`ZDoikyOM5LNzV(V=RMZC wl)mG;%nfFgA)BsySb}E%D!4?MuF&vx!av`4KI2E3uhHCfilFm+r7*<*2kHuXs{jB1 diff --git a/backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc deleted file mode 100644 index 2c3ff88690bae23ca724d9583f2bee623ab16ae8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1816 zcmah}&2JM&6o0d`Yp)&02?-7PP+^jkf>A?3G%2LU)JaSm9E{`-2{n;c8++nd*lXC` zK;lagJy1@3v@{%pplYlB16-5bdP+$vqJ>JW+6y<+RN_DnePd^1s-iOX?EB5kdvAU- z@4eYV+6H+84#feF3QZgl!G)_;+rp&~e99K?x zOb>%8DH(L#G-Mx4se`0GHhn3j8%+O{n(_>v1<*SIrbx1*q3t5H^7p~u*3h+QkeBjO z$Xl8BpDyH0-7b`kVHVBhSbQwSrpx7X1tV`Ulh2sknq>vU=H^Ac$SiK#xN^|%M_p^! zKfuoAEX~!|w)8cNXUj&;V%c(Wu4FK~%+3|;>?}8MrRKo$KNXA;A>zn3pSMwZxREay zJnIq{(u@5w?3+K}x%+{MdnE4?19rotSg*CD|1LY$-OaGj>EsbF*v;spb5E6s;*VC$wdvBqOJ#wwIqInTfD~+ zzsyg>MukKM`yuSUUU&y;I+#)QGpG=N{adbccr!B+gFVPVSRSW)V21R)83rXKQchxhSP%Xb_HU-<4pKk_;N9c|4ntGh_zp z%-%;F@>D{v&$W8;9ISlmXIm{gk&Y!|2c7iu~3(@LZ|k z#mJ4wi@z}6-`E7IoY-jUxqoEo!o8ty!gs^>BI`|uHv&7JsKM*OYr(ayV-I>Cw6Eet$Gfm%Su=|kIu1I7=39Qh7z%9b%N}JGMX|Lf9|0ts?Y-qHH zu68ImzuKeV%qpXZ9aYmxdcn}NP{8qOTCSYcG{>u33r4n3cDy3&7*WTManpT7-E>wf6#zH@Zl_ts_4pPn{yqIv-2z~_~9 b+O;7!U5#If-)#P(_0o<#e!rQ0@6B)C zd;7dmU+)7Pqeo{mO%lLweBlhS0ye%ta2w)aK%B%~5n_;%bQvy`LPR(%8Y1P-#EAQ} zWJnYyU9o`UrY85oqLlZUe(NDaWP$^8jiKz$A%nuW367t$cgoSY_$hQ%Jkg zuAry5<~x%~8>*Gb>-{vWX*4n%r>T6wFd3cB=4YwNEGwg@P1>o>s`J#!Nva!g~zyW_~>TGDMR&yK#Ek;XNqnV9l2AAcG$|E3P$+&9sZc9%R5RPYQcrnzX}& z-#Oa@>ic+CAudjAarTl`HnFYFUQIgncmjQ^IN9dAHP-YT6n7_u034~tinEEkbRjIH z@orpBlW8b3>qQ)tiaXy_&E*O;4+O>HWgo4K@OWY*Mib$|@sZbSt*SIUOoz^nC(lIb z*eH#jP0*3^WAQ}%jdfhD-mz$6v`V8LYZTA$z-eP;$97`1}W?(M>Tgv9sX=d1B zMxV~x-jTT!yU5$5Etr;JyRo_AL(i;g=-6L_qTQfo(Nr-l)nYm1Y}z8WK(2l%YZGSj zlXxKP_9_E&Qq?qNQ}5#~`@qF{yWS}1N>$C^Z`3xN<|6()h0E|7RaFF zUl#owH*lBBja+}Zsf3$-L+!$Hd(Y5L;XyM$|G`c-?mTE05Zkh%xn)7-pleaxQ*_cP$huKoI_zh1AX^?o;Ahx@-(S29 zkGyRUy=`9}U-j<4EIpSf>0ciuAo{NsSB2I!vF_8zmB`Kd&$j#|(scpEHe)yX`wPKG BBrgB} diff --git a/backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc deleted file mode 100644 index 7c6161afdb4cc5bed35e9ae61c7234b1d791c3d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1848 zcmZ`(O>7%Q6n?X_Yp)&0Nt2T1N2Jp~H3m{zm82zgESw*N(9N3T}IU!AKQj>x$prmO!A=)D2YhqfO z2-pDzb3!)iyJ^USFeeX?`q&QTq;9aob8;>)bQVDE1ehb)j)u06&?h7!wF`#pus%&)a2IXEVRgT$F^8!xt1mOnHfj-s z8JjQLOgCIZbNQ}sCdbB7GpXrJ-3)DX-OyQ4bqzZfe##G(i4jk9jU^Xlz_1FYWoZ7{ zxeQ|e2>bT;cy)(>@l=qvi3Pnd%hziyEBL4(8<$2Pg^4?l3I34WrWq*$7Rf_9d~gK7 z67nP~HLQFz%lECdyx?EDFH`VuZw#i60yJT4v$AiMZSqk==BV!h=_2soz)svL#4pFk z(k zjs+^cK3!i;)yg@fQ@tf8XU4N^COI-W{xT~Y>tkbVbb2yBm0_v(S!Q~Mjh{{BW^(^W z@?FDBC+rc+xoo>FouL zB|}@p3P3`F7buns1;h44)0!`Pq46cnSm4#{QOC7CsaU>*i@{5(ZDB#hM6XpXqN(Dz zSVJYlave|9$`)6@SoDbD@SXUrd53o+aOSY=6kg8tV)q|~V++e(v%P33yP7u7EN6rB z1%95wWvI$D6sc0FGq54O_(^-$O8i!QCGlzE&dlR|M{m%rj_#G>&Eo1iU($Q; zZg%uPYVCj6+W(9~XWt)@w(IF@>5a~*pCXy(LD+k6C2=e9TWkMwsfJe)Hxe)YLNdR$ z4OAN5YU)`%dFSH2!Ta&M@q3BQrW0F{Jx}E5_2{+eM%Srt$v4i%zB7;H_>Xe@MKy>P ze>*oZ(ktTVA#$~Aqyz4d#w)ha7TEQ(RDUB+Cv|7NI3tXN5Yv0 zj3V}AMJcJKr6{q87gQ9ztSO2YRGno@Gs|9(Cmr+Yg)wVtQ9Eyxmg{tob=6hRsg6;n zOsHVElXZfiM@uakiemFerj{v>V)nj*Z5)H$h96Ikm&*EL(MZ_)afo#b&IEp{lo0Yv z2oQ0*6-diti2edS`2RK3`Y6=>Fx36|>CMpL%Yi=wZDhFm28fZ5E1R@yOKiHDzLLJ# R{7K8tBCApmyX{_d_Ya0mu>t@9 diff --git a/backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc deleted file mode 100644 index 65e62f3fb863951f4233958386f47d115f7dc204..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4887 zcmcIoU2NOd6}}Wj{rM+Kf9%vr;~O_^EX?z~xk^*#XOJw~Q7p-wC~YmAf}ly-VNH<= zFXco^XJp$;fo=mbtZ<8BXdi~H-5%Pt56xri=3y@bjCw#iq6Ic=FL}%A0exs6b|{gQ zC?`gPxU?mnd+#~-yXV|<&OKM#eSI+mPx+y`@?IRFf7=cBp&eqo2*fLhLk8j~E-*(K z6ujxVz&YBWvE3J(3!Y;P2BY&-vDX!EzemvdaFS}ZjR?mKAtSmRNQ4@Hi{+JNLogLx zo5Cqc!gCo8kIpHkctO^1Mj=&IsAH~9Ou3T4rm(2W1PhvkiENsRwoGENX+suFnM}pL zi&rGVn~Jz1n`8>-bWu=otBHvTJlHe@aRsCtR#C4RMCKQ3l`8O0H(S_lVWB1|rp>OX zz-zC`#lxbm)+!poiiTHO%jDg$*ezSbLV~h_rhM6icSzQj6-^e~mOv;1jbFfL`+NYQ z#}Ed?eh*CeBT7SM^dLI>ON2D)Fk0|rion;lkP=UaQg-_oRG=`eIL0A6>>P4f@QY3J>r2t05AO|zN zgV1Su7%fpp&_chv+AtC%usFT2-|Z=Tt13R|_O;)xm9!uiDhE%(fD}TA3%6+DAlP|# z>qOc*4};bkoucz!`=lOjqq!6pEz|vIGO$y2mdXQWYn~-)$ z^EozEz!`QXRnC?0Oe)8*9!^8`$oq$c3{t&tQRQofihrCcPM=K`N0Sp12@hgV74*=E zY-mD7_8@Uxek~18;k~n|s4B8%@=6=iujbkwVperkxq?SM0XxY;v~g@Po66y5irIXs_#B>NpBwYBxWxysx&dM*;zBu>!$o$6EwY7a z7BX_HMj@k$l<-eLz}1AR?p928gm+K0yFjfYCww2Wt)IK47_ZfAhwjK%iRO7>FSCo^ zJ~en>KHWK~sIP)Axa;hiKsO<22o=4inRq5QpDOvs4Ox_xRoF(s#HDPW<(VL$h_MT>1S?n`qu1caKfiq z;F9k6x$;vtK>R|@U?V(eB(y~Q!(y|f{^piy*=9F>&viQv$ac^=F_JB0*k`*c3Prli zca-&fhuhB=It%{M_DBcDiU)iT7~Y=LymhxRCx#u#Ut+l%yFA8oVGG!*z>J6 zl<+cCw%us0qHSl2>AdFLd!F$Wb)(LUUtgMO-@NQhW1B6*=2%F4(a!QP28qtU}{}eIfPk%~-xHi#0f(rtqy(a9?Q9T-g@ZMfuVKvn)>@E z-=g1%z8(E@&(>p4ZVe=F#*>@zCHpAzsK`mGU&h)>ucB6zF+|2UxSbTBcA+{ZuZ%|U;QtH;*V@225=7?d^PoY=#Adj zdSCBwr7`o-0F^!z`Dkz)=nbX^x;Nrf z`fzAtf6%5MW^llGJw#{)M1i6(tAmPs%l zGgFzP%*Q_rLH`Zr0MIuc4cqil21airXrL1@E6nqfF7mt;wtWOV%8J2;3o88NQ>nL{ zC}N*Xivomb)6t^KvYBi7Q-p8U2t04}fU3={eH(29iHrgGvLdG{x>Qr;GX{nxWJyH6 y17EjkilY7-2vfoB2N1RIb94~?{~3+nj1GMg9eVY|R`lrA&~|8rqRtq@!2f^MjPYUs diff --git a/backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc b/backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc deleted file mode 100644 index 70aba34fb1e0932663dd3c7d629635a1bbb0d0ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2027 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 HtC-+l4@Yes diff --git a/backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc b/backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc deleted file mode 100644 index 64145a3c71ce3c84adea285f59af3ff3b12822a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1965 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 Ydw=8KVtcLOfiGCR?L};_egg3R2O9;wM*si- diff --git a/backend/migrations/__pycache__/add_logbook.cpython-313.pyc b/backend/migrations/__pycache__/add_logbook.cpython-313.pyc deleted file mode 100644 index af6e584a0fa70c160f0ebca072cfc8d52ef9b1db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2321 zcmcIl&2JM&6o2Dgf5nao2_N}rVfkvXloS%0v_NVV8><8t19s9*enqGSD74r{JLX~KtRjc;Gp;AgvInYDjti3i-CCY`dXXp23-+S}k z@6F7vw70hbwqJfQln;FXzcayYd;_dpMQ{zWkR>TF>LW1-%ASV6{h_oq%IqQEZ+-8aN3{s?mGb>bpuR8W?f6$MQ9b=3L_2CwHKg33MA@# z#OBjcV)GWAwQ%%Nt)OT$=bE$8kNq?3D}Uqa+d#lfc3~#Ri3XcsifK44<8e`IY^_XS zoMb&GIe;_d8khElK_ipU1&4M3Xr4)u@wTj7G{shS+N8&g+#Sb}yXUa`1=0HjZ0Pht;lfdO!Iu>SRI0}zItjUZ%X zG4ByzGscQ_@Vv`yhCJB(RIaTmf@qW1i%p9@*Q7qyFmVgh3^X3aVb1(e+OSOPW!sDM*?u zP_0}vqg-{lO=z@(M}_U%g?RG)@pN;Jy-H+~2PTrj#K*^yXk{4{(W;cFa3Cur)00Pp zovW0YLH}9Fw4?&<9}xO2L(T^786w_(ui%AB}QRPgXjhv+AsLBXy zhVe?%O;^{~4}{!25zaBF_I=PeAVsBMNUStMPA`^JYEiiwkyNa_ToLH3Vp?Xk>p#l- zglL<=z8ki8hT>Ih`{^vrl`U%E)d{u_k6(crHm_)T-3}&abF{?PusPE*Y_F=H#h3oG zlA$4EQQls|f^s;f>|B^OpVKv_eOk2%HQ7RpCcCE@tvM~pve-D~ZSF3{_pbZl-A{bb-c=Vz?uSR8w!^xubFr(j-#P}L zdL29$yBvG=Bm&{}E5Lb&mqLBF_FO-GbM)J>8)G+P3!$N<@Y;ug$fd}|$lacS`Q!76 zyBl{e28MnN3_V-+qs2ew6%6^jH?fyo=!p-&4H6IgzUB~pvn}4q-42j=Byc-8%<%pY zPS<=S-oe-WJi{S^cC|2JI6_dT*5TkzUp(rsb(45sxYiS9cn5P?+v(^>9X!yM80BiC zqlqEz&H!8g&X5;p?u>GX?SLp2CEVF!G-UfFv!dk`-S)FWV-4*#ES03@PSRqfz6AoZ zgmz{KN~(9Pquy>*(lT7L5ju diff --git a/backend/migrations/__pycache__/add_menus.cpython-313.pyc b/backend/migrations/__pycache__/add_menus.cpython-313.pyc deleted file mode 100644 index cd25b782e3f596ab10bde52dbd359ec6b3627952..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4324 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 B>9_y@ diff --git a/backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc b/backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc deleted file mode 100644 index d24a9ca274ca861ea368ccfd7da5a18d9c6831f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12036 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 diff --git a/backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc b/backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc deleted file mode 100644 index 8d421e5e54513efcdd20fe145547be956f4f93a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1946 zcmcIk-%}Gu9RF_il3YkY6e>SdT+|lRDYeuB6)_SZwlN{pTr%Sf%-kHw5i?#ccy}n| z$?8M3FAA+gAGO$N`ycoUzV?AiJ2I!8X{UYhjY?;Hun+xiejGUUvEAJ6_w(I+e|&bo zySr6iUkBKZeQxBF0>JNFsWsHgZEF$59gsjGF)%8U7zA}!%ypI;l<_$l6VAE~H-kx% z3{*XLh(j>x?Io27LzIMeFnp6<$vto$KxqY-gkFsbf%M$99sbcXWo1MuN3wb@q%y~V-%*+KfJFBTI zQ`BY)x*4RUPwQBo>K8Wu)XS>nwiD&;Qa;LqZM;G z)v$%UK3%kZk-3aI!`*CZT88b`idXPY?-j++@i+tpyHU~5RW>cfQVXhXnYNHA>fHR2 zW)s!qnfSS~yBr4Qq>|0buRSO4&dl5O#;h(o#sAFof_O>r+3)=k;zN5=0*7tTlZtQ;4*!?Gk)}wy}_S}ly zjIFhv`zero;f3ZyOQFTkZ;jnA+-1BJx*dA?7he50wt!;r4gbFT$M0TR?)!G|-r#a* z-QTkj*!@&&xYcm8VXbZ8LC=H3Yb~M2;^`m7(=Ru@+~sd`0eN0?j~pR4+J;-;9trn* zzNU!2IUGJkS40v%A+GoiaQv1Zr&oGN_@uD%zQA!GLBExL!tn{hZBCZ)DbH}Tca@T1 zPheFDaNNj4t~PP|)#fs8sT=8}tDSu#`{~0Lp8w%~H_kllq=;=%mJ3Q=m*t?}_A2~N z=8LwM7aQwn*I~634R3m3zH$u2tYRrsim6tPiZ7>H@yfx#K)O;;W!d0wak)G;#gcsw zTd0Gv2M1dmEEM_s5;8ha#peyoQ5-fYA>;`(;P(sc!|zvL<6~d@BVYShAFlh3T=hKn mw38vSIRHZ7Mrob4Z3zC)V%K5|^aX& diff --git a/backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc b/backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc deleted file mode 100644 index 34e9c88211283c99bd36e2385a53573ee9d7824c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8003 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 diff --git a/backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc b/backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc deleted file mode 100644 index 6075f35271e4781a8ee533bf92242e026fbec32c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2467 zcmb_e&2JM&6rb^W*B`8L5(PW_RqYSnsB@ z>p*fs?WLTmfDk?O&?}tjp*KkK2SgM#)v8EH<-#q<2daKZePes`$wyT&*1UN$``&Nf zy!Ymf>#B(X&Q{sjgvLgCwQvWGp>SW^BjppJBE} z9mlXL#G$SWqeE0nCpvXBX3TI6MKxVgHmIpb`-!bFMKh^tDWIv>G*{7VkR2pN7sLTW z<0@pDsyby3Ic6FcDM_SH=kodEsk6hmY@w7$k}|Waqy<1404?aUVY-wlPHoDyUDZ^W zeF?Bf8mq)#J>K|6__rpKX;_pPF0DEMv(0iiFNd_?@UwLDM^d&KNzK<71q&>fS}vJ& z$gFDCD5)AwRduy#>O`M#j~0^H3%c~O3uAlAnX|MacbaYGZA~?`jUXTtg2~s=>*oRp z9YF+QI@yW}LIW*HjB*g&X^8zSz9AGu8CmEMnn2@zHf%zi6#Ypt;K$b=bC4ICQOK(? z1Yg%5{|83U*Y(HeVsKAvYySCn%1?%U(eYhAw=ot)f2YPx0N)LOcX`VNwtJw0!>Diz z`Zv(0p`V8S-eEMkerG~RJ&BNoH|-g3qHU%{X#(#?@2~s3;oJ7j5>0-~nSy>q!$^kM z_dt9soWh04=2*sMG$iz(GPm)I!|0u+{adt-wYJb9YsvwRLEu-xO}&qVGERo(&%N>z zyEeIK@D#R`mtJg;Q4seCCrPD-iae0B%oPT@?3q{s3TXA2}ZLJDIg zl0Ba*mWnT`8)SHFET2skNG3ayn#h;PNGe~DlS)+}&!3(tjyfCv(b4t}NCIenT zpYkwu_!T9@du!cB=Yp#13V%ZQ+rwXw*4y*$%$yfvHA`6+Wo@9m8@lrkkSFL0dK@RK z@x+68;)kiV!2Sq%)bYm6-i6-pOKV6t(pE>pp>`m9I-VgR)&XlkWVC1R>yU|Zoa93C zdf%$ty&`x2CU<;0dUbTAv+zJ3d)9~Aj(}zNpl&d|5ecfEgA?)gRT`D;xC!9Sn%HxU7JC7$@(`3XGmRXmf# z3wZdXbWH&62F}EVo84(Ka0d?$Nk1M2?%qB;d`h~vzrmltX)N9Qct5N!w&AoOEw&%v zeD5yMUL3%gSa9(}iSt94ub<*{2{)+Fpkg!=6_?_D8Btss#Oa~%(n%5cWhBDZ%c2Nh zm!ksbV;FFj<5K2mXn8Nrbjr*7WX>l-u%3*1Aw|(`O;NlM&mzb+FY0`0!n-+5t8>jf z3+t+@PN@!UCSjyP-F!3S!ir+4RjMeAzYvYIxP|J#BF2lh7yhIQ|3ggrDLVjDsF}#= v1+pe!jGqQX9IVR-#~-4D57E&_k+#)H*ZoM>cPD<1ym>{cOI?_-P7wbWGhR%o diff --git a/backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc b/backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc deleted file mode 100644 index d8f2e6105d887dca12be09d95893bf52f0c625d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1731 zcmaJ>PfXiZ82_Ff$4=@rKsSw20S~Cq0;ST5Pz#JnCWMYA1Stk>5=fSDoDel}sO^kD zr)s;br-^ll9d_7t+F_UNUO?&@b!b%9Dot81y@7II+IHCYl9<0*pXK*`@A>zB@B8a& zBoYQ3FMo0iDi81{TlfRlwNcHW*aQ`9P>ISViA_-QN$vq}^OWrcl7R<;El^k>8M$%l zjOd01afsA6Y)KW`zz*fPSaAKz@N^+>>u#ZJjZh_#!C3iq~1|C&(%PqKT z)G=MRVCBODv~1W~#WA%yNYfq72aV9lvQ3lsX~8v1&Mk_l1{KvE>Z9n&l4&7?mUP!x zG9BucUA-87!;DMEh!=3pRTpKm;;dPQYB~r`k4a||N zFnHo)&6j5X>#02FOTdqy1Tx0D6XRhKg`_{J%N)tU7~cswW}Dgtx1eVCxl`{qsI!=* z9LG2)K6z3wI zFNmb2g6jo~<$T_>y+FarmAz16)i9S?pL=41z?1B9vAC!k4`ZCiWfwhSI;`v{^=_V+ z=q%{jtj1E(X!u6JZ!fQT5xZh(CrG;q&$424e#GewJcid%dJyg2k9L2*c*uo9^kvIC zTYVdS-_0HZ-_ukDzM~nZYc0pXM_OD-a~C$ zCdz1VJ93@ry;l&m{Q-#!f$cj%rVkUgKU$l|!f{U65&Or-gq=HNd`Q|E6qtTbK-!%# z0l)5YJnFjw!Lz$!Fy0~VMo7F#-n}X_y-mdZSlAOaEn7A;%@bKVG3{R1c~r!o;gVTe zt0!E_>aM=1J7&EaLV43o)l)BNnx&UaO|#kWrowh7 zho4i_UYb)&Ba|L`=%t6;+e43l54i=Us8p*)iqu?k1NlJJAF1=aUSpsrQItOG&6_tn z^S#-bd9$wh{XPWa;rB-IfJ5kax^V{EU}`B~)=?ViD2>z1B-Syk>?Cu8)mcJiu1VJo zPUi@k!zsZrKU{-o&OL0fY3KpEa+LYSk@vWD$C@<*fo;Xw6a;3 z5koP}qE;|SuB=u{nnBDmxmh%G3yLN+pPAWtRq+iHSy?uvqLx!DvLYRV7!fjI3y+~2 zWC?KK3{8eJD0B=E0BBLpx7tgJMo*9=pc#~Ma@>MA3w2( zhxrA}1(?@izTJmrn>WFYvLgs-xaCaIMr);Meg>aK?=)jxk8NA4hRq*&Czq2^4-z2v zy^tRbM{#o2Uh_DQM%iAJr#^n%hu*aPKVtPp;|Lk5CeKg|0>1*b?XIwS9CEK-dC9Ap zTwU{#Vu^1VjPRvXt$;361oX(K=Cll#R`qaBl#-^te@o197 z;@6@xi44i4X5vR+SHC*4rQW?7mrm&#FW}Mo|LD^{Z8a}NVmz6+3H)@e*p3yp!DSzW$)?0lR%Eu_H|V3hxkJ3*Q~w6?%UWdjHzvK=IFm zK^qZ3x5eP!#!nD|&%{^=ui>!~{%aPvbsQ7fd)?z4^8+3m<$vr0ZsP(TyUK5L)cIjN zj`@xEIzYY|z~d~x*>;ig182Z`a|p-$uFZFO%8z0yze?#Au2Z&7IbY1jZHeI+$8AOM z_^4-Vgaf{cI5@h>aqzV2V=3>)fKwIu*kyP1JdSk=)e8dUgKm(Ae3o01v diff --git a/backend/migrations/__pycache__/add_price_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_price_settings.cpython-313.pyc deleted file mode 100644 index a2324127b0376098aef43ae8f2d7850b42f673e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5504 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 diff --git a/backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc b/backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc deleted file mode 100644 index 474b08a8930c4e27a10c25eff548ad4b13c6463d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4597 zcmb_gTTC1275;yFe8Jek_{QR0;(?@%y=7Af&~9_9Tr&<1;6%3Bl7ciE>;Xox$Ii?+ z1uH_;_MuVKHi}l!w$fH&H<30g^W9AI^e~}yYpvpw?4iNWI6iFzGqndFnVYq6? zHJ7!LmL@!EoVu)&bU^FeNYc+5qWZJb2ytG7W{rI)Y8b)$wrDhqBR17R_1cGK^?hih z%&3tj+Q#brf@VSEYs-a9lfr_;DMDI2O9v#LQ+PU)mEwz>%(H1J!AmmDiHQatn-oNz z6%;-t(+W42gAsI83?<1ND zSs|*SU=+epvr**K(AEveT-{k9R^p&IX#KPAbcze|K>)q{FH)RRS( zf(T20Bjv1zG>v&#Kt>e)3w%E6MKh(%P4Z+w+z>({Y}*-&nzyM+mLnZ1uX!ujCdr?0sts69${i-k`GlE`GIug(-<~20(z&@hULOGW|*3M&J zN0@sJk(ua*BM@QyF@}!$e>TC;p&%W;5~G>vP&5{8=m0Y9J)lf%2?;vNL_+=v`f4OJ z>5p8a$C+#WO+~K@N_>$QSx^auW6UKcQgRobnwX#?Opu8%;Q$k*%VM%uNcfr?lCzmi zQsAYdEU9vbm@0KVHbJ`JC`kl~p8#s^cwbss&hYdH{zzcVAMu_U@ExHfE1Z&*n<~(w zOwd0y5u;CyM*P9pspejlw8AA>E|t!T3V@r0bO*eHXZp{yNY6e>iJ#{sUX0g59cZB; zrWIbMW6X4{IRZ{mxcFj<7Zo`x#08($rzzNl#1flHvud%Em~$@mPDu3A)sh48 zt_E?nWOH4$Dk>K?1L!y#bxH-6v(;(r^WHA^w@`SLnQrKBIS1J|4bMK_0`}#s)l>;$ zf?uL@#`oyZKGoeaeD3`>YOuABSZ~~F>!YfzA+^EPcHDaD#+nNFqXGXY)4VfObAD(^ zmBkLJs*IFQWaCN=)aDLVxp+EJZ6<>)`%r?HxiD66$Pb6zEwqo6q}Kt zOVzgJ;iCq^jVvcBg0fsy8t5N7N~tsisv4yG9}z?8kzLHD=6I>HHtM_?Xr5d-4Q~R~ zw36ATYMe@OVwOv`7*R*}#JXlg9vm9lldoEH_1m$wE=v6;ceb%H9S~_*qducVUd;-+ zIUZ(G!N4!^@vOp2P`SW_r4*Gy8?zMWOFO2Zl@+O=OQwIxOFpVV#nYl#zIdAMk$ zPMDsUkKZ2KF|}FT&t2}1llPJjho8F!zx00T&Y!!IzdHT*T>jcG@{XCJR&&x;M4B%9 zOQh+v?~v1;9(B6%Z+RcR^~jWW4Sj7L-nE0toxZL*BvyjNN|0Cy5-ULhr@g)sk_%7G z7hf_OWIcX&<<82k9x%Szdly-|i--a<9iE3nt2Z9}==1ZRo_}z0yJPT$)tPsVJ+p?M zSVP}fo&VWs1F_e;ZoujmHE&e zJcmEQflmFOv_P%mV6XPGo`6&HHyp6**H8zLYaIsQu63Op?V{Gs;Zdh??Ys@>$Hyq( zKeqQU9?jzc%=G9V4|V{(j@rT0dOHq!sCBcR&~^-V)}5wNn{M6X9ku8-3_8%=Fk?bn zbim)RX$i-P!SY6ziE$e?dixotVbemqZ#pocojTxex{2gw4+dYG9#ilg-KNhQ?9y#@ z5}R8tOz18h@VDL}l3N}O7`A%#!S@YYr*QCH^Oo04=wSo!3r3brae~OQx$j4^q6pKT zuI%%(bd!CavsZp+pyTOOCdn&&ug_F4uxuh7XW4>*lb6M~kS>_y8%Zt+Zw9I5{W>(l zGXpmVzekj6wNxa;KPfntmCU46wNh660u_Nol7lde=Tk60lKe%<1GlhdNFImNjuvCQ ztI=Sps7JWtpXhD)|F?b&W7p0H8bq0I=eD)o|Df9PrZZ2eq32ZlZ^l0yzi0TU?J4Ch Ru{~c=^o{{hZs|DK`yWA5GMxYb diff --git a/backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc deleted file mode 100644 index decdea28d037046f21266c1003f1c09305162067..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2062 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 diff --git a/backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc deleted file mode 100644 index 9f97c279e55490c0adea91fc517283cd9a819ef4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2092 zcmb7F&2JM&6o0d8ukF|&5Gc^_H9$(R1SUmI3spnqW{pVVIFUcpN<`W&-ig_E?M-KG zM64)6?FCRJt5Ruupc02(`cJeC$sYhM5_C%?s-CzxGEy!*^v&8!h(Ss__UwD}_I=Hp z-@I-0_VxgdUoL!YW`h8Ki-kYr4%5maa|?37AxCo3B;f?9JSk1cT&6-6m<&t=d62>^ z$;N#14SED-LnEZU=HXm$0C=P%#g+QRlzoNK1(R8ZHqUsGRjPE3+huB2N}L%ct{qyn zDQ87-Qx(1bVLNkNe(V!O|FnzawZsq3KT%ptb1G(QM{2O{;qvGj#b>`gA_eJ&^YF(@3wDB?HL+0nSPIVy$H*15~RKc9BzPPfV^;9JtI zqXm6kpZ@P?if@UC+;aC}$oz$K8RvDJC7h>m_8o>3;8iQ&pK};u;Gcql7@m_T975!X z8ji|`V2->a&yjd2Q(w%b(y6gLE!tMCT+u3enbGsv=_v&BoXyL6)wC;`Q@qT|dZMl4 z&<`)9vME~CFIlW@FiMAB2Kx|AX4Fl7QEk($8LFRs*(|Y1h3Xb(y0JjnRnw_DC#dtO zIX{nAsy>@c=TljlPkxY2z3QqZRh5oSrwdb=uB)0UOr^5pW5-8MoQ_j9bv{{0=jofn z!-?TxnwieiOd*}FpG+DCGhX4hE4_9}>W#vTn#{j44{p+2D&LjEC^fqrb!xuRK+H~= zxtZALAew1VVT(i*x7*3#(auKd6EpUF&C;v?We;BO^6HC$UT9+Uif+}I6Ay936}Z7m z3|o^MVpmzQR%INU3b_4FQMYui*%hYQT&o~;70WJ_7xOmvtw*s>TC%hs9Z7xYNQy+8;_*y@NM9p* zBYJPw*n_azRGu3&CAoJ3g9n98P}l^84L+X5p4)ub(@n{NsFsyZwAX9VEBO*g55U8M!+L)gJjLqJr}CDdd(DQ4}tpC8`uyem5xi z_Ya`_?tY>Ml)DGsMt)@{Q59un*Xx2mDfCy)5H%cFIjbPQ3Iye=5)pjB;}!22_9Q)u zdN{PYov1rwt2<(XKM+FAc#j*>G{Y`xni~?QjsS9doX;%X5*=fqjghdSW3SL1*2Yn! z#H#5wLc*HYKQxVtWA)DWM`$4nTzo9t@Ox$3s9EeB--Ao+W8mN?1)4G;=O%3gAb@ZG5UFHz(1b^rhX diff --git a/backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc deleted file mode 100644 index 92e142eb50334b727e14a054c8ec7700698164cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32113 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 Bm1h6| diff --git a/backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc deleted file mode 100644 index 2f587d04992402ada831bca096d36a950456d753..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14233 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 diff --git a/backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc deleted file mode 100644 index 56c9453dbcca3375adde700af014165d730649ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1928 zcmb_cOH3O_82-nzYp<~(5NLSh!9bEGmP*?+1@Zu^hM3l|F{#&D7#U!O*fb{fhA6_&N5ipD_ACV7QZaa3>!V0}jBXtC{k+&;{F(3B-u( zxkv#nle;u22Eia1XoZgs0~nqRNr|qJ3nqEwO3Qfa%pt$V;T_wZkTjdu+3%O3L{36dG??_&4&z*|t!!K_yIAVCn0)?DZCY^FU7x{(2% zu4kBb!!+rENjH-|P(Q%EQdyYXRp*m&*`!+PYumD(wbSTZ#jfq#TkE!79m!&bHo`@Ni1Our>3tsm8M#Dm|gl`BFasw zZfa_7o@sLh%eKnhuX0c<)L`-l%MmYV_y#yWZBEP1Vp{Odg5%Xo`MhR2 zLcy3WIsWKeR-55w4z+C45%tm){L^i1)}7IAnASK*nrvBSG6v~3=c#PE~W z&V`{nLkr=r!i(w0?MH6WjXhlp`fdHe``^?1C)f7$tvB~QYVP}+LdVg+g1c|VZ^TzS zCVmN?d+vj_-i7d;@E^^6&&3K}2;U07_y_O$8(Tod@J3+ogX4>r?hkx7bZ_W>cr9>j zBiQmpYP#8UqiMDC#KV?{bhUkGT^jyb8h)|qLyLc{+eq@7IC_X&?;L4|dn7XG{e~j? z_F!a~E=eSEQd;uw=lIP4E-xJ;k<-G`djiJ;1nrgv3CAZ1*EwCm!`_iL-!dg5-r%wj zIN>MEsiV_MqK9yg?Ldo&* zJYx!-227Ny8eS+-VueJif@uQ8EjDiCm>Sg!DiTQB#k)9G>|NMh z3iyN)RZUK5Xd`;)A!%Awz2#pBpq^<-1>L4nq@Hr4B~lLQp|fiikku$^-|l-eZ+3n& z^JZq>Ze3j%aI8I>Eh-$qAI`=bxOd0yGLkzWf=Du8R4FkC?tK~GWol67$Y_SU>^J-j zCP_}%LmncXFv-VARbT`~t__UPBrp2=#sTCOfJw+TA5!0A5f$o$*xt^=GboS(i3XlJ z@@$mYoTV;UNCT=?C~9hcPv0dxuGewycKZO{0_L2AXc)Ujj={vfW)8pBjswpG(nO?J zD8MJ=4$b-n(0pUQoWB+UFyZ)f#~66=dNhq$I&6c%M9|aDMLh0+ikydJ@JesQ)Ai_o z_U8AHSWoIavAvw6eYJ5HXb`g3aprNJ#Thvc-QenB*Hm*91OZO%9Y(>*Px}~jVE(as z^djwmX>uGU_B(|K`8@!Qih+p+F9T!LizwIV$quO375!*i#;G38=8*$Ej}!SZS`X)Z zo=qnjk!p017VxYNohH*zjqDxdqrpo5prSBC*UP3fWvKF1MW5H$WpN~%V0pbXSJs%N zGfkbJ()Fv7A{R@GlAh1c&B$7QvGQ@4;TRkqP373=U}8A+GR>;q&=5 zc4U;L#?#`c`0wsrNaa(PBy*|3(G*K(hf?D;*rKu^dExB`Ce<9sH8PTYS#)enOlL2# zOX(~-RfCMW&}`)*rb+$(iRwyXZY?z6_$X?My1;a;_S}_vxrA!UB2yQNre#*zb532@ zL0!tlf+1T)UF&1fu;E-@wtq^+`@rVa1vNissRsVPUp#zO)wuWOo3RMT4~EDx;2zl`4< z-EMkgrTcdG*Vf&U&5oYUrr4Gc+Yn-pg{E7Xo0*N4j~)r*PhO!Deu=TH ztlwzudGO(bi-un~wnKqyGu}iy{{VK(&yxTE diff --git a/backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc b/backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc deleted file mode 100644 index 0a91b34333ddcc1819635a0b330e3689bbe0630f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2213 zcmbVNO>7fK6n?Y2{@?tN&@?fBlTZX!UAJjOC?PS}Nh$>gL+wODN~+ajkIf2u?e1}d8l1TXraugX`jEwN{K z>vIq*wVomFKYH?Pc*^o_h|Bm!@ZH@G!HPHBiArs55aB>245YE2G8~0HxVseMU48r> z$dGmz6RQf~0(lZZ<&uuE23t1D+0hc)Y}v+W_u3H@rFQ`&o&U#D#$JBKX}jz!to)O_ z%0;;N)lD|d8#&o1=^o|kp{thvCd=Wd&5bc+ndf-c!)kT#RS zqRctPNS$S>q%*_7#@DGKpUJUMDTp*tEhcQmr@YO-P$*OyhzySmCg?~cIz0HY%gDe0 z?LRp@dLq7MLL-BxN8+V@m@^W#Le-wFluPw;PGfR<8Y!;mhF)q+PZV=XN;X*4w6v$c z%y#;sVvy?RlwuL9D*3AlwJPX&S+nZxp{3v}OU5wyVvZRs-5&6o&RijzWtu4{YNnuB z%rzZn7~T)2i%qfA1l}wb=}phzREia?E6e0{L&K}1a30NE)yM%J#UJHNAyrUSlQ7*f zqLi9}T9W!W-f+?uhZ-tQn|0cRD%I3AtCOtm*YD%cW4H*91n;M@k7G-X;rqhTH7EE! zAQ$`B1h=E_VRPGT@0Y!^;j7_WBdbj2xq3(K9OwIGLA#Gboi&m#;PzZ_rMb?7$vPG8z_ zWJT<~EA~EL^Pt1O^>47SORWQk$y{5s8-5_s7Uz#VqT9QpJNfw*5^Z(PzusoycYG+G zKSH8@VZK*DyZ|j|w}3i`TM6nc_{(^wGuq`^=pfO3;=*3h!f#tH7v3e7epeZHdk4Dt zI}m9Y=;Rk09JsK!z170OGVXNX@?tlSa?>qId09~))q*@FtJV@GY$495m~H*>v~j3+ag*3v2B-#VZu_5AkP>CxkrV1R|_^fi(OE^}oXo z{Qd#16==Q(&DZwd3|lm_f^9Kfv)i&_%(_V{sB?V04@Lk diff --git a/backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc b/backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc deleted file mode 100644 index 63da3202e6ebd61befa8915af2e157a331744976..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1633 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 R>A)>sSmJ;W>CfN>e*?s^k^=w$ diff --git a/backend/migrations/__pycache__/add_step_title.cpython-313.pyc b/backend/migrations/__pycache__/add_step_title.cpython-313.pyc deleted file mode 100644 index e306a6ce2882a31dc3cba013dc3ec9538b7d0368..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1566 zcmah}&2Jk;6o0d8uf172ZcIu-k|xs0C@AhU6l57$-K2^wY4X%#d;lf zHzMfCxa^b+6wKq+;WbE1Zo44<4 z=KW@y@puQ|_-*RjN>T*)n=gWaYkxE~6t|%OHWWxfm?btrDb5NrqAgOc3(baR!giR# zBGF=PuOs3dEJo6#rLd!ga39!mSx8CiqnT2Px)s-Cv{*A&sup!?)MmxXGSeNGEj!dT zuA6N9Irq({h!=9%s*6%$R=Hxa;*lf}!Nr$2n;xcm6)2XxaQI#m4&fm<2kAB@Fii?@ z12_CRxh>|yF|c4BwD6(nAz0v9{81pmmI67AM|3O(nJ1bY*uRiL*0HwA7zJ4H+rSlC z{DozJ3PO;B!XRehzjS*?)57tr6v`*pE(Y-e`TyGKNfm_xPb9#J^+X~66V7d%*Kodx zGkz8>fM0tkIQ1yRAUKl}_Hap{Fob>NJGv|m!4kP7E|F9?zit(>xvV!aT-6`H+Rb#wGEQfwFmDRE>KD8V!)6t_;8>N~2#IcBI;B#|y{rpkG z@fKkX(`H6#jk48><2o0p^J!&yxnh;q)l67^II+2~s%;)B4em<95AHti#O2^#Wd0YEQMQl7b@WR&_UIZ_~R#|byWj0b6 zyrfe!OvBb|7Sn6CZlUxfvsNxM+Y9j{N(o-5ShFlovYE4Dx(??OrQSfbQnvlma~6$K z$-jfTzuocWH80Y7%j{0H=Dc=(#>aEG2~QKJ>l@n}_06w0?{+`y>Hluyn~i%Ddjg5i zkftcU*3ks9S2+MNsoawGu+Pa|W%!{oysL~nR7Rf0lJ(iG*$2JZ$Fa#LvE)G)oEofe z-rC&jhQv#CbIZK*_LJDPrYHkb=gA$SMx-A^M0W?(F7X$l%F?fIAleyMP`UFiQI*in zdtr`0JcIiC14NBW_s_h-@kMUaAVf`s8iK^JOiegh4MwfZfWnVV~MGOGJL)^(fr z!f(ot&_oC}|Acta_p7ziipf5(dvJ;O37ipp?1_YsKj8I0q4O{3$Nw|=)UMq3NbdXo So!{l*n^IHiB$w=$(eyvp&|0Jb diff --git a/backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc b/backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc deleted file mode 100644 index eb03aaab2cf395b8198068af66d28beffc6058c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1672 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 L9uV5hZHV?a;FuD| diff --git a/backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc b/backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc deleted file mode 100644 index 6b4286bcbacc367248299b1a9c7b4de7a92615ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1685 zcmbVM-)mb{9RHsCGs)5>wrjB&T~A{l40Z#lsjk+-ZJJ5fES01MrG?8~?oDN6 z(Ke1uwg*QM${53!3Uw32e?n>dqD-*Tit{o-^l5LI8C28&+fM4BBYj90aUq^5o(qKZGq?tKl5?t9i<^pT7)Rl2_+y&m` z5%VE1g=H}~l1Z>ky_nlG|N0`l>-kXWB7!JVX4t&*NX_s z8vq9OZ>g*0b+H{oD$tU3)>cOeE+`{JgZ7SfJ;nL^}SIkMbD|@R!Y7 zEYuK&|$w4qSajawlY zFKNhgN7PoeOxe~vevqT(?fV49t8R}uB>0#sD4=8I$7iV-v0YN z{U43q9Nn~Q4ExNLURUet+Bz z!GSt(=p``p>DboF-SN*RKbgEcwI6tKFZ%j7V_&V@8~=9l>&bgl`_Z=^1pD^-V?P9E zz6;L$9PInE=0(Fl4*MD|H>x+Pe_2;C%xj_8AlW1m6+UJWZIKwu-Wilem@kPG7PcXP za651UkG4Z3M!4-$f{Vk%m5=zQr}*u&5`bvJ*(C%Gc3B}7 z5_i2M=J)UV{4VYnQSNwTxuEEVELS3_vSDBcX}jnX^xunK>1}@rks^gsUbD5~h|dva zSuJK{*%1|M&B*9Q$7ikNm3-#1R#-b?fJasE!>CwVgC&iQxgi)zL6c?Eg9UL|vuu10 zb%bDcy8_Q-p{SPg+LSqfTfBN;&EiyJ2_cUdhH!NONZ=k5-@WS5&1NXI2%^=wT4Q$Ye#-)f&kn$;RnzwoCpM_GPX~7mKERc-S z&}t(6uplK!{lE@uLKoPP0v8w8K9lbj3%21F%hm{0b2;i%mY2)6OKtX`!W@_8nc-G! zCd+u~jL6pm0iBhMqDix5vr@9CTc-DmZgz=Ty2D(zXcZjl8jB{AU-?GUapHwsw&Egf zQ=L^St1-txn=|FCVP=~g1qnA_}f(2h%Hkr%(PRVAmit`oITwNRZZ;dYu+rowzq49`~kpoX+ zD=b@aneA7rCn930=z5}AE)_sM5vTWIJOHXP5cwxJ2nu|vEew_2T+@eRA z<5v@5V4V8%=y;dDV;B6-z%Bvs+QI1*h{9F0bGryo}NPSpQzE|A)l` zPL9y$?Qd)jt`B}cdjR}EYZdsOHe5Q|kARQ1;~6et=y-E8z8-%(w6ApUD&2o7?cbyx zrFJ`~_mtF88?+7};?r3Fp@`ClVTktp^)l931ujB;(e$JAZuk37+Me*cmnQa<$v@EI z(E8Bh>-$RY?@I4K2LcNIebiCML8#qW|7&Lh{rxJI>?7+$y(NCjBey}462IB4j&VN| z^^W+(2kq8EMs$_13#dWR&1(AKc%^LL1UK3ZGHbLFhAdy=0^%(*8! z4@~^QEwR#SJ=@`&f&WFrVf9jo6qq|x&wE(cEu+MA-S&SewFLbLRb;{TyWI=FTPo)& zCVS7mh+Awna0YQX;0YmzoIpZV1xV}}TzUpqo<~~uBVA7;UEkgQEpqvxSQVp$+MOu= EFTTNfVE_OC diff --git a/backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc b/backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc deleted file mode 100644 index ea61bd42dba852846a55d115155c1fc9724c4ae2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2261 zcmbVNT}%{L6h3!m_LqU>PYR2b0c>cFZTnEuYXM|M>&8$?1eLMT^QSb0&opP&_N_36CpY&P)278l-1d3Hs*EIN;Fnh z!VW?WwujkS3AQ~h;vV(ro&t77yrVwdC%`llt93L=w8iGvmDUat-A745r{3X|DC(5= zVEp#bD@0Ea^uTnv=!FMq;+LP_besiZZ* zi0ewm6wI_BClW$dQ_f_mlvL%EWacu|5aMYyJEPhAIyKUUl#mrQCmE(}8iFaGQmOx` z?IxP%4#}BJh*+FS=S-w-s-+Z-B66v8ma6erBlO_nOZ>JqJj+%P@P4L>`!Ti%i$El$ zEJQ|weK6(RFT~tMu_7pCPhbS^>@?b(CD+)fiwBKNl9;b3S_D(Y`lyrU&w$x@2-LR7|Y0Ka|B&;|u-coI9gd4Y$6yZI!rYLbiqO?bKj zM7uv_n;=P=VTyMW_JFJdXpHEZ3OKw;#_<-ba(E@`RMatKCEf=R-Ty~Z4#L|`&*Ma4 z$1k!PGr&ZzzEUAIrI;KfdMB9*=q|=D0NvG0k|gY8e}qkvkU!r!@UEqvKY}WXmU_1a z>FDUlM+e3xhGW9SKzMBU4J|YJ43M0pb==g*%d~!sb5Z~ZvR2+|nqxlap z68ayVYGmVaY8c6^s^;<)vZ_-#krU{gVwgt0GLg-wN?bPSZe!=026r0#b-^~l_*}_k zFynl6>8(g&@6$xDkZ&&W;^~=;N==ODRmaUCzvWibsT9>MPSKKS-R@S)b&BGfV0q{q zjb}}&+l_AdhUelmV?Ra~YnVDd7wIoh%|&%Rt%q1-o>46>p4K#rP{URc1i@;Zah@<^ z8ecByUoTfjCTErFSxqVm>UPys;%D5$=_#CpC!GIcTYX9wt&FZ>^wkvHn&D(>ExwY!h1|M4GLy!3CZz7i>tM$X{{P7nqsBKy3 ze_ji&vK@Qf54ew??LqEueCP#R*j?zq-2d{q8~8oj;Br-OmNniqZg(!u-kQHLzj$`7 ztbMg}aK-qg^X}}=^Gox0&#rY&7M&d3;Ct`!y-(2T*rl=6#`Zh%9dWgxbDi(nsT^I-qwX-V$ z7jCT7HrTk`g&QkfEOIQbB+bZ*CP|?x%OgpNbX<}wk8I?$xRSO!cCBEw;brFxi_6I7 zY0Ir>2Gvc=kM}RD@zZoBw^K0QgpB_@GHw=%stlnLA53dWC1r6V(c|NmFGbCw*b&7>1qmG01$ZXkh& zQ1KA_o4{W5p7&l1oDW94zRC?>@V+m2 t{gXA{{&ViX+*PD!>mYFaSNS!zVUxpRxe&Qh{$1b!Cv3661@%_+`VR}P0xbXl diff --git a/backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc deleted file mode 100644 index 3beed02749ba3e66023ff1ce00208b0ee81e19e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3713 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 diff --git a/backend/models/__pycache__/acknowledged_price.cpython-313.pyc b/backend/models/__pycache__/acknowledged_price.cpython-313.pyc deleted file mode 100644 index c72ea5aa6fde110569b5316f76d5caf3f8b85267..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2834 zcmZuzO>Er86&~(#f5iRM@5+*FiMD0g8(SL%v5LrXj4GC8hpH9Bl;p(OhM>sNnl<-F zL#`7EExNa+H|0=ZbyH5YZz_7Rk&BR1a%oBILEb@wpy)}r7^?4mLvm$pR08`rGw*wE z-aq#+l@bs<|N4h%d>2RPKWx)}BEjPD2`v7CWJHk6$)N>K;s__Qb$B5pg-O_3^9#Hb zArUD`qEd{+q&SI7U8GAAh~V)f3kfMnl2VGKq%=u;XVHa>)J?iYbPAnAa%>pM@j~}> z*uR(b6uWZ5=C{JE_PAX6!o+$J>-UJEVX;)vu_>-qh-ed4du)`8V$H&264pmZvFbs! zM{2oIAEUSI2zZ1V;dz5>)0IDjFM{RqEwi#5Ns673wVP@ zWt)H+$}izBZ5mswl`z;(<8vUn1d_CSxooW0@Xbou0;i3#oeR_GUA0=pI!%?lx1v?d zTB%Hv1e>aD0P>Mhg)8|FRSRo=RDBAm_MXFu5KCV0uqrGDny`(Ah2bOoPAdX=^9_ATtnX>^(wf3xoHkeB^N| z_kkrtATbz7^dCvESPZnaw%=qJuoQ9e5pxyq6A^0{F|#^v+mLNS#a zr#&Y+@gNQ1*?eb-b)j{Z*wsX|hTxdQG#T zLv&0NI<_=oR9TB0u=#hus)F{Ow2Aerj@Q(hY12#xTeUaiZ02~k4|cFtHx$NUMkb8% zlL|~M57lE;YDB|IaK`v478Avf3Ha9+AdXG}Lt_hm1eh(Wa1p_4G%g3uQ+^q)qOp(s zduX<1e5rI;5uo_ve18q{VcK#}zUkUDzVGrqI* z*9YG`*!k$`{at>4YSx{a-Je?CTiU<1?A}^--d=H5K6M^`zW;FDeYoy?N*rQ28QY25 zhoAsGL%9idDGNMYDkut=S2Ni~v z5M|_tApt8IhOYnxVYOVY*#5u=H`r(*1B_%DVR84OM#J#oAS~&A{Ma|Z3nF1jBVCNJ za-h+gt-)uMjIk4uk+Y1PVBEUH|Z_ z=vUUC6HR`)HG27(wEKtVm0Qix`MR_f-Lk%h^ZC};m1hs1r<;?vn`3wC3tQPn`0J4- zf9F*cU7G&e%JZ9l|Lps#?!|lc-)v1Z`Wp83u-iM{D1zIEH-vyEuui|tI4zumetz5D3<_&+m0WZX-i)IZ)@Y20xKE;RX1 zS|BsLCpBk2ZeG6U@FR_totxXA!9`%mR-fB%KF_s8b-2hQr6n=d;2U?aN|-qF7i+@Z@&z6d%pBaIm+f74CR)o-_8BRNyI z+{}FaPHW`i)2Q>_U3X;B;ZL;&&hF%#X~`W}s4u>XqpqQs$y1HVr=!o$yzI%niiEp_ zpTm)Cw7&Q{%|&XQpAeRd#au7>70ZcNH!vx^e>Pq{QNda_RTD;cX~Qo`uMoZR^D57) zH@_g>AoCScBCNLkQunHj{drPX;U5fPS@&z8vo2`-y5Cth$UH#UEU?}N@^hHuxL2VF o$G=V@ZuCbq@)KIxM@v7U8!o!>BO3Z|beyYk4gNJ^Z_ls)0ql1J6951J diff --git a/backend/models/__pycache__/backup.cpython-313.pyc b/backend/models/__pycache__/backup.cpython-313.pyc deleted file mode 100644 index a1f04efa1a696aebe0de4292f31378b4b77659af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2403 zcmai$O-vg{6o7Z_Wo`V!|35L9-{A14gocnb$`49OvMvO50acZ&)!JUn3VZFmUDKE< zlGRJ$<{T=K8@&agN*uYhIWd589}FYMjdHH(2?O5gCzUrn@5sE7J6)4}vJBHzV`o>=`=ciAU2h+eTa z+9)(Zlh6#!VJ78^_)n-Z!V+&%HWWk6D75}4XQ)|CPDgmciw3A#ig@p#ehKx&HpamkpL)ZncCOa)Es)_ zW@TMTl;>EMywf<{b2V>j>tw&4sL`~cf{hOK*#t*`j5 zl+bjBZFrAutT2@ln$NIJRkj6J-i%RdMQvL-PR^QIs!Y4Xbev;a&oQ0nn6@g@73=>Y8Kt|~)!ySowzhwnQ#(+f7= zk8G&I!a&R`h8%l6Ref(&KS-^Jy_9gMFr~yk7)td=21syLPORs14^-XALgB=Vy3L^_ zz|L^o)`Zu}s_<$S!it<&3$Lq2VolLVT^W!ZU@77v{v4az;_#;z=R3v?=9eV=cl$8wKj^9$np3qxl zUpx~Y-&;AD-T%Vsohe33Z9DE~-4-`<+&lKa-CY;=_!qG6v%02=b6azzsqLu6O`)04 zrM=;U=KWFY!gTS$)@n)IUbndEW9*(j7})>V3SBEc+}bE5w!gNxYscd^4sRSSE}7%Y z#Yd%;od>((^LexNlEp2fsj=&a?XNyEM;D9prRklK-Kpo}X7h-}Egp|tJxIOc%#nF} z2~T$2&z_nMLl!rWqXsV@bo?~)^X-?n%>l8jHygqhCmv7UJp7zGn_Mn_S{mNz*c~xD zN6n_Os<#%jIXeFzZ`9`09rmT~)eY;;%8{&?S5}X>j-BD%ju!*phs>@?i(5sef8vE| z_01ITZ>^Lbn!z3#yeqd3H>@jjM_g-ZV`pu5!|a(b+b4hF=HOao|4!%|7jXf-l^}v* zA=2Vx5uSd0zU^GYBl$Qjr}5s&6v~-uXPce9c5d0ZNb{4_y(1^Gkg=1$d=_Zd*?$(~ zI6m7@M?KO!A{;%h?`B{pUU`P}IUwGf<)e5DZex?qSHFznPdCf5ubI$mruz-E@GG7fa5Ps`j|787>ACeIBW;T8^+=&|Z0s8W?9Jyc3h?GejTsh?CusHgN)DM&rGGrP74NL5$Ky!Ymt`DWgm zdCz<4w94@N{zBdUQDN*4T6Di?XR^17ldqW0bg9HN227Jc%F{YhifA&(ngWUzg{T&T zm==e)mViWn%cVX|1yxHzQcFQ9&?%*~mVr#3jj;))M-MSQR?f`GVJzs^<9ebrpbf&H zHUvX?w%k`#cL^*;M8YzC?%P!^6q*%dBBuLbyUQYR71xV|sB<;o_Oe^zD?(Z14}GB! zCgRs!pD%ML$|82dtk=0E($!!zDmAB3bwvueWBPW@_3qmBq9l~drpGH`UZIS27JGk6 zbRcC0lFlHaOCV3;VSu8`5G}`WTZ$e%FG0MVDEHARmQ_9WE{%wllZeM>kb%DL2&sLx ziJom*??W#2ybKw{`#W689zK9vsV)cFrq4%U5G{u~mYFv!``@xWu+MU!XE}@!iD!gR zeeh(Qu@2i%&t^2(#ep6ldB|`OJ^J48818wDm9sa`gnQmE^%0D882z%H7^DB^@gF<~ zG5(Q#JYxaRQ9Pe)H%rKs6M8q!p&ov?hmRwExRb{b!u6w&Umh&xMEZ*9aMvalU-e&lhQkp2VS*Zivvzg*UGy!pL?Re(s4CHvqfyJWF73P2`k1u?9zyBttRi; ze&sHAy;HU&)Xu`NtvTv?x8XSElEX!m*dGctATsC*><(ilMF~c6=<5BII^X?=6j9&y z9ljuv7WXP(*QvQz6AceD4B}LPBZp-ytzb@Zi{CLDjxW@%+VqPN7$>4c&27{vo>0Tl zGE85@OJUL?-bq*}w~$-J1A-bnWyND6VHmDi<%S_t!>HD*hC}m|Vcc(+P6z2Xj5{`X zzGJ)GtsxvY469bbN!l=c+InaRDNF`v%aEkBWZU%xI-4#Yb$E3R1E@GKLZXkv7zupZ z7*J*55DFoCKEM%LCP*A5LGK{sNYFJ0dXPk{;aA++XOJh&1c@|>cSua35F>`+QRE7q z2fjp04Xik(K)thv|4d|Y%iQtGZ#BWG`U(sn9tcX<%i)9YC2K41|290icH`w}VM}R_ z&a7QtkF9%;k}YMXojd+i+xnt)Vz!l=ThlgT8{T8o&$Y)-Y%V^V{bjg0z7VMW$ElXG zK<0B>)8A>^BhB2k*Y?-i*{N?HY*xPg{MoH$_R8Aq`q_=bqmNt4mG;!>t*hH-np5*@ zrH#=i@)PT8wK-U5Df8{g>8%fc(6(1w7j8bkZ8j&DLcS-9k5`)m6D?(_UHD*YZ2R2L zmwvj`Ea(CHusM)#Df&L-`ECB_2sdXn|ABixwUKSpKM%jrgJUjTs!yP z=Gj(mI?&BM`t(<28cuf)Y?mHS?9BoxCQ&RD4+I4Z>MT_6zGFIgovJHg&4YplZ4s0^ zXbUR0$h@{ZRfB5KRbi7+orB*Sa|wS1AVZO;U_znm^Ez@+>M%2U-9+L-*y9)BBV1CR v-f7IQ%`ur2KO>9+3SDPdfA^ diff --git a/backend/models/__pycache__/cost_distribution.cpython-313.pyc b/backend/models/__pycache__/cost_distribution.cpython-313.pyc deleted file mode 100644 index e92d7420cfb3ee0daecf1977d100508e12bf490a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6674 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 Tet`eCW8uDG8vntx&=mC_*hrR_ diff --git a/backend/models/__pycache__/cover_override.cpython-313.pyc b/backend/models/__pycache__/cover_override.cpython-313.pyc deleted file mode 100644 index 408371f1c1424e87163e6c840757f81838607f17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4703 zcmc(iU2NOd6@aN9OBD5E(UN7^PCCV1=BSvRq_x+@E7mOu+*WSfE_B)r(7@3mEwhnH z`;e+(pxXot*xP^>K>-JOQBYuT-hvH7hdp)ML-%Mw5333Eq3Dy}TG+#YKJA=KQkHDF z$+{LJprd>5x%b>3-tU~lgJe?R@cGN1ey8(kj{B4<{xjee4w_hamyYM0 zwGS!Q8pT$=t96jI#;{fBYE84&IJPFbTE%Lzm{NWg;5gxiscJQ=uwp`?V!o+CfduHP zR!|I;nzf2z*#*l`)~!|3F4)R)O|zy2VTl#ygeiBN?2ohN3a@G~r7By6b^OPlZmP#@ zTZId?x=~rZsOpBHK@pAkGs*r;{q6YLg1OQeQc$|rr>|+<<~B9$cDK#fv0mFMfcB^| zBSoa>l@P?3PGGel#91wY{rH}KK}dQQX;ctWZq4rlgU9M=x6Wpa`MqX??#v)E?NB*$ z^Mc#bEho5HuPO;R)9{yC$~~T?N6r+-lNc%Iao-6kDW#-ADSah86M#`kgt6*SG2NWd85JqVV-Zbj#DqR}EvnA`Qlk@?UnJVIf=bWu?$h}Kq zdf8RuBw<5Y(;je(#c_w1Wva-sBgpcasn%;$PRQ~`U8#ARL0MkW!Ln<*p&2Hsld{Zw zf~yB|QV^zP26Yu;R69e0?k1ciL2eSBK;aB_mdsXYIid-ARX3EH?7IUe(P=X6Wk;xh zh8tJqI46%sfj+X_dyLcTTaZFE6ZGn>4^jM<`zU+9apRNN;M>vWQY-dUZtjCyEpeey+RKh@&+MFUWiK^u>?Jer%x^z^r?fNNN?vTt@8!;HzrIs% z-n=)s8y?=vjBZcfdA{+oZ>`im_iS7IKH5U_{KM?~`9I|UIMx!Mb6aS_ z{#`y$KV1FW>fhgJi9czSK96v*(d{$MU;mlkePN}=S3e5{bCKUH!ZZHP`a78E|D%KP z+>+~F(uikF5_~t}yA{u!oTeROF5)z8hG~8Gmu0o#a_;6dp2@MN%Q3~#G3CpnC)~vt zx{GnWG;KpuS0s9Dcn4)RvRWO?2GL@btLECZf z?F^N^=L_1N^;ZT;L8pD zEGg)tbyES-3VpO5xn=PtUAkk>F``@+(~s)=D7}=xV4f`xP$bLpDbko7s7f zP09~Dd_Iedb4EIs)4h)#yw(Xw_Y>G1=1}Z2EEuF?)W#y}1s}qn1Qz$jhoTm{n(s!;UD?m)A{9^E#RJi};3|Y`-tE6A-dU30H6cBN0ZZ z)Q3bjQsxlHK4!aF9RHtK?8HY#B|J~7`4NfllIWzcT~X~KDISBlMe0X_4#)Zj3S#c5 z|Bkuixu-bt<#CK+-l>`8S4}?Odic}b>*W^z^ByW`Ym3DpCo0RTS)p$dYon&r@Y88+ zt1B~M2TT#r{lZ`;%Dy_>T>ogyG#1^@1s*?;lx6&Q2IjC+G?--z%#E>g?ad_|{yFz+ z=!ftcc1EYbSudmbEEEU?J`eJN@V|$+z)IksoYv;FkGXGs%#9vK#6aZj>xUfv9bTZa a?Uhr3$o9-3hku6?X)K5T#o-^zT>b;4c4k-r diff --git a/backend/models/__pycache__/dispute.cpython-313.pyc b/backend/models/__pycache__/dispute.cpython-313.pyc deleted file mode 100644 index dae6259551792fb3edb89148c8094f37a9673032..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13958 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 L{HF&0(%1T5r8*LI diff --git a/backend/models/__pycache__/email_processing.cpython-313.pyc b/backend/models/__pycache__/email_processing.cpython-313.pyc deleted file mode 100644 index 28bf8040b6532d6e0e48c8f98077814b56111b15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2578 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 BNoW87 diff --git a/backend/models/__pycache__/field_mapping.cpython-313.pyc b/backend/models/__pycache__/field_mapping.cpython-313.pyc deleted file mode 100644 index ac1a769ca345c19dd01542469a2a075eb2727d96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3009 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 bejj!;LtGp?o&V2X#>9Qcbp3$|QlkGCur0k; diff --git a/backend/models/__pycache__/gp.cpython-313.pyc b/backend/models/__pycache__/gp.cpython-313.pyc deleted file mode 100644 index c991ad9260822fd02605465d1d049ad554d3f3d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2918 zcmc&$O>7&-6`mEBKjN=Mi!voy5@kh7DBFtdM0Q#`vaL$8vxI3`D;sD^V6j@R=!M87 zJ-dvp6m5Acbt-)*-~v75Xu%f+zVMh{8eIZ-L7-@PMsh9R< zNSd4^a$u0i!9wqh&y7u!TpI#y_aW^uIV?wVeNu|1GNcsE#`X}N?P8&#YU{R9(V2*? ztiXcvx@J_=ayG~UvP})M#6t66THzpUvG8J*>PE@T=^HGVuUB+xXiQwuAKFZm5vT7Su)4FHWAecC(TDs<@?#h7Id38g;H7pP+$b@#u z0`*P6`cYB#QGX!-GZ*3K|4A1O7D9z?T>3&-4!n{O#yvlg18V~OMCI5_*AY)WfbI3L@dIoUu!)11J#sI^JO;=<51BkbrXY9BsQ-}l z*n#zUA-#Owjdxgbe}gb0?fL8DBZeSqO7_Y9;O|5sB&R&5I$Rpa4zlR7{)KMV z^##+W8+)*G$(J9@t44VvLtR5g2aBOwnYAj-Twls)YFVqh#bOB$qj;E=tu7X+QP?JQ zLs^Gi;5W>o{*ZMU#eYTC4O_dXo7S{ZT-*{^$gG#k>S`Ibjb~AeA{8NlJv>Q!xUejs zR;qQ=-osflzg@M}G7JqC^)Yyqrs?CBqHy{j%ud3R* zQmfW5aexKAh+8C|9bq9wG1ZE$C@icfm1?nG#&%Ru?$_0_XGti^nn5kQY?!)P1#?hQ zid797F-5U)8ZMB=Ayb-05yPB)hG{bxteTLZyTcWQcBB0S3S4kLX^NAkqbRUW6!$xQ z4+QgBHl-2lPM`>)z*Efvbz3v559lOn5-6rnol+DFLus%GP`TAQ%ot*x0^2M26BdOz z>Se1?s!i86XcAOhU@F#kAbv}B#q;0yO*C%4>>uA2+w)HUwZ>dC(6lxq9r4=k$f+%9 z`&XUQ3(uGCI3sr(QY+B19)aoZ%Yn(S>}}l{xZId;PPY0sPj|%2h`+u)*A|}UzX5!% zvDjQ}UEI9Y5pzEV$nezG2E&y_R62GIO8%8{LtyibVM0Eot%DRJiqaCXYBLF_2%8yXHIP7FXHF);{N>p zJDvRb|3N1S?|_4s7ZWw)Y4& z`5!qTQSIk&gs9{`pq&A*rsdR`AR%6!{RjRA3q#AFKyBW7__$#hH{^}rbX6FE6-Wb_ z4S?5CAhUg{8$^Hd5Y3cQ*%K^ueQ60UooaD!7}Q^m|IFhmy@q^L&ar;HeJ9z zRMll7I)kmFG(<0;4oSy7fmmw#(|v*EvBu!&A=jMwzAy7XaLwrHCoAm*XLOG9fc+@i z5$AS?r=EP=o^Xb*aw=KbEO*4KzzvfheSNWgzJ34M?9PHSvD~=Xy72h@M^`%H^6vQA zuaCEr?c2{rb|#$hrADsR|2X()v?DIW=Ao{RmD1CQ;mihuZ}lRniEZ@rVicB0PM zokRR}$6Y;KxIO%6$qE$rLq6Nj?*Lvqybt-O8Lt}b)ca-F_xJS5hI@{7@(#(rAhQmc{ecX;4kU$N2(6D_6MVm(IxgH5n)Yjg?|)t( J!XRIVe*=c0(~bZD diff --git a/backend/models/__pycache__/ingredient.cpython-313.pyc b/backend/models/__pycache__/ingredient.cpython-313.pyc deleted file mode 100644 index aa8ee9242e9954cf914e2420153025f8998c4389..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10776 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 PovrSB+kk2tV9oq5?82j* diff --git a/backend/models/__pycache__/invoice.cpython-313.pyc b/backend/models/__pycache__/invoice.cpython-313.pyc deleted file mode 100644 index 0dacf5f55043b29c901db84f19986faa4f955599..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5952 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 K|DNfmnfg0Ta1;;# diff --git a/backend/models/__pycache__/kds.cpython-313.pyc b/backend/models/__pycache__/kds.cpython-313.pyc deleted file mode 100644 index 7a258d4b2809d8742fe28928dda0fe4f2f0f279f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4361 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 K7x2x$#D4)b-w*Ks diff --git a/backend/models/__pycache__/logbook.cpython-313.pyc b/backend/models/__pycache__/logbook.cpython-313.pyc deleted file mode 100644 index 6fb53f6500909e5f02073082035792088decee4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7298 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%0D}pTEKZCKK>`yP8wd!7u{5#=>yMrhU~fo# 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 diff --git a/backend/models/__pycache__/recipe.cpython-313.pyc b/backend/models/__pycache__/recipe.cpython-313.pyc deleted file mode 100644 index 7c6fe629359e8221ac50a280c21b595ead14d4f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13987 zcmc&*YfM|&cDBvo5re^i4TPt8<0QPoWAdCN5J+MknQ(wi5))s858z;IXCFHWB)O$k zADKQRka}ApwQ5FH+eFh!q^VS<(^iabrP6F@8jbG zA<5i(t6Ma)e9qo$ul+cCfBRc&?|fskSxxYl*!b6AU7N}DANZ2`%hVpeA!d{52PV-Z znukmt6ES(r#O%U%%aFyBK{D8L#*pC2B$=KplEvPIp=?hM$?+T@2Rv3{_2iOVPaeth z*oe)OPx3tlq`+e*c26NGWcryyMV?|(>~Ro>$4Q(lEo-R6Q%Xu*rZQ8VNz5)ci8{UE)Epe5lq_N9sKFq#k;BP^=hg@HCP}_FOsCBUtfW_2; z{&{I;p+6FqW5gc}$5i`7ICwiQrBmD)YSxH98kGX7ZIS)@W+I{ZVpz>1Qpk^ulIMd_ zXsyuam!%p0)ws;oUcQ-TQ_E{2X3<0}qM2kg!n7iSm_ahVS@6*XF;mPEv&Ed_LbsV@ zdvhS|fM`8#CI`G$Z!XSJZ=RUjjK7fQwLyMf4_L)N(I(~}w~%~;rl4yUfM&rSO*?27 zYBn=>Y1$2%g<{e13{qs!&C+#?#bVI`bvg`nI`v$qw`2^K+1@`^q6|uuXmyvc5(lA$ zrKx&Jxz{R|YWZb)K1?aG0_v-P(v{*t%-3`(QAeyIRddzu8Z~!B3dcoh20zQr0mxbI zyFeoGC`=UDH5(x=0y8Qq$u565;0grgd3bDruZH@**iuwF;cDh8&8`QoX4GhQ!-of} zK21$ltqZ}}%)Au#1p}%O_QTA}Gn!EA;q*>YHpOk94AY@ z1(>#$VBp(aO+6Cjdou93a#z-pFSb(txko0cqAB55$|P~fnt z8L*s5CBEjNIE(^k13^$#v*NLtaO5sIf+;wh1Sc!$LXizZb^3fVRxks@1t2pWht5My z-H`VU{BKh2&|N7cd--^@MVCk^Br}24$o~f7w@gW4_?4r6Wh7bB^yI<%rt zt2mcdM*qE}enX%oJu7{WvL4Bw5c-m?)@QRDW1rpH>QTn7Q`e1^;nk+~;x)*-0rs9= z*t|lk&!jD#N!E94lx-dR=QCfPq4grmxJT`-gdqMj%T(9)G?=J8wsPUoI=q!wvsQbxUIMLG>Z8P4TlGe3Yjpim&1N;zVXf6{y57*Kc|)gG*KulgTyNA> zr|Z_mnp0Te@dI{`ndPeZRHoj4R^E>sM-q3H+^>IIT z?-|Qxrf(dlL%M#8*F84QKGZJ3`g|D5xwSqYeqV{V*f|385&gplc24S|2d2L4(!>UF)K*b&P3UG-zGYwLH4kai--lXbqv3I808=jkrfiJG|`R zX0}|}A6bk>VAqW8!&&%V#Wp>*-<1O6(DvqCT!U1Dsc}*RlJBE+@HCiW4Vl4kK)Sc{ zKbTvOq6vhW%MCG_vTQdp0wg5g=7cTY2QJf zN?Oo3FbbefKq&|#&T5YSRLFB8@mMq-W0mjV&c4HT>;j&-6qG^%-|g6vYBwgt!@-zZ z7$s8Fhwg)KF&M@jTP>W4L>6`@Wdd`MWO9ggG$;enn3)gWkpepio2q?|L}VF=UMS=P zRyHH4MGFC$bGcwlT4a1jb;|z5X@4{#r@FzCzs-ru62T!#ldb6`#xdL$PRL-m;!$*M zR4e~QI!0QdW==pDhv>xD4s4Au0R~}|^MJRaTZ05qRtr*0ZhFawHLCV8M&2%k=ZF*t z!h9h(qn3;Oq0kW*|2(Y`5}o&lp&dujaQ2K}p794HH5X{HRtOApJ7RpAKd82-pMW`S zVlBJ&@5!?4_}k8*5i~ZfbIl*!EmaCYSl`_ zq^O!HgOt@=o^)x^KPRbKz?gfjTCGP6iehLxp>ldGhk4LEU z5K#QgAxqNTyAj(8Q1=kClJlhav5gisC4`}5UHeA%R-?VQr8yh=jJ5Fs*D&mwf zc9VAaw68KrE00ho^v0L$KC7G)l|TsQ%U$yj&X=BH3m-A1s-2c~(85lfF~gQ**BRyP z6=nK1sGhUTSm0;TaxMc(boXVXO0?0kc3RkhCHgXwAM`8dCqPd!52EE`7x+v(uhbr; zm0i@?jd~X>$(HU7FZg##;2v#RVjr?$^#Ltvpt&$&OUXmM8xNI0EHh1eGJ4iN_S_YR)`CwQNC2)-ah!n_9%_kgSN z=o3WCyt%+*Ayx+7qD8bq>?{{JW}ZPak1?;5CPc?HP4vfX20gp3mk$;p-lcmKR_41E zfUaGuy;#>RMBNmNhT6H$nBy(>I#5^Bcj)?P+bNc$YYOx0FS9o*8Dy?1ISHs-eR3AtsvryKr;VUBtj4=EU%gV@pp{S}u&rWQFwy)8{SC#2GB_h+R*vh3xcUEUU zf!!>YtZjR?teo&D6F%DhJ!K(EYj3ZNtoE+guboT?w=pU;w=un0`&oDkHkgr>i;rek zuYM9q2qUn0KF``9FRWYRw9dmKS!GbFidt(Df(N(I4>mhD{Vz`cOEUmo!gGT*dYR?s zwKLl0@Rsdtzi1b12d*PeFK$iI8j<1RF`5V4oS1Cx+89nWpJOQZfZ7{g3gq0mpiv{bK%>f_!6HXu9jTtHcQ=q*(A6DW@yi|Hsp z;0H-jw~kTzIO?3gli zL-BZ(#bw%bpMhixEw0DbHFj>aY_}_yt}D_V+IW|fURqT5Qn<@u4o7T%n4A6Oz?==> z$_C((Zvc-pwCz5$6qu$RGz+~&IEyssNYl4s&6)#rmB<3&UV}3M*w9|X9)}LTOeScBl06ptH|h`SWe34D%_PM z44v6QzOn4+hgcP`3t&}nb`@s_lCUr}Uc`bQ?}IAB*hD>8gTM|8AsLgVnr9~drOe% zKtAvsZ;@Dy8k!dK&%|0_J9X}Q5`~PNWf03%7o@O6{2|TbYLDXG6TpioL@y(xPX@h1~-m==Gi(xt3Bx=p8Ze#-M!D3w+vGtlbxqGXO(fEB7To{`cr^4ORHLGN!v@o&j$;q z?=JkoFNA;JOv0G`AH{!iVIA!s{Xvp}xkxfn7-7>92ct;oGhvJ(;Vpr^TYeg$idfvq z4sjy%&3M!a(PuYBNc&t4uXF4O@2*iY)Ii$c)K)x4EhG%!Pl=__3M34vT#iDRd(dkK z)Bv9fJSwQwa0|*RvU>nxC8@v*btDG$?I7n~c8;;*6>uN$AjCBfSLY&RNiEdSB7G?G zF2tw@ON?B=DG5JCk>iVaWr;8V$i`zR2*xbKanhX#oSI>nhj^fnS|~^25NLrz9hQL+ zn?q;}hlRrgJ4VkrSJ@5?z-t;@)^|4-{Nu z(f==RDObQ#k?COUYm@1$oscvIJS6^JiW)Gda3(Gp&M1 zn7#xA;gPXpe5j0)0Rdje;enDokpn?B0)ciJ5-!LcMM#qb59p+?;62ij{*OZTXm+uV)qFt?zpNg zRno`KwHx$%2yjW(c%GU%=0&YHE+y<2ciF*pxx-c{!jEca_MmyGLwD60gG+!RRu5@J zm3+9WHwQO;eH?W5A`LsMOd}1h;!$_)dyu6Nw7$y$FQXqU)Ukmj5ZTFd6a^^$5`}<* zF_gc?C)PEFMeoL)v#7iWa`5xAZ;YQ<{!b7HIYX};hxzI1{f*0tCrV3hD^7X;)7AII zcfnDa+nnC2eG%S{)0PRozh7O02<`+%aI-fpo9ABSZ#!w-WyU@3t`#JN%Le$uU~t3Q zDLhgP~ZD<6&1BNGhRjy>sre40AkXzqQ~Lm8N$HIq6Ty%Z)n;NT$a4>+s; zUx0)DiG2?=6~Gcs-Rw()1fBtgu)JkE;<3LVV(J;X=KC;4bm|L~xheFT-{9ZBT-XRCqxOoGGo-dr=Ix88v4R8joYB z9?Pwz&se?*nDDE7eE-(VUFoB(pR z=0#1sa&)F(Y*e`vrzLk3=e?f~#^4<4)@F?&dTHB~GQX(Am#AwweMNeiU)iXCaT1WM z@v`B*#^q!~=SIVpgEkCuCwRqZ+$m(Y?LP&PB9jEix$+r6i#t~W5aV9h6V!mtvFPOc5XBI4W2|W=^sFhy4~D` z#T^F!DFFL7Gi(o1v%q7=GfVcZkdeW^3b-BehhT~=F7Z#xKAshgvHgqjVfK;Pts=I6 z*)bPqj+&n?-4-E>{7{pfaXK&?{|S$O8vYp!abl5D6zwSHP+-uRMdi5v9K%<(m9d^O zg01(HA7x@!37bE9Z}&_hHRlxnrw1RBe*$HgLniq%5MO7Q&E~I6^*=UMd}XTr%JlF@ z?7y!}A5znYKQ^p?Q!qXsZU>WTAp=3&E26=*z;ZiYQ)_R^sF4W=COK^YuV1CJI`` zD*fhz(Qte%YK(e8%`0V&WK5#FvC4%$Y^mDRVP+apR-RDWIXHkjRp3#Z?$hKu&>M5H^Z6W>9&APYu*~_3vAj7 zY#It|+yyp`85=yMrW1Q6aLf3G<$Q5naDM|Z zuE+4&of(0%qINK6&K{~g4R_JY6?0zDp7C3`USn(@)t&`y8E4~8as8KyhfL%gH$cvZ zoZdmrwjjz;89-$P@aM=1%Xs9SjtJ74B*X(@_mZw~6EPSig6v)<(N#Ac;vSiw)+z~( z<_#-(@p+L}9wX9ffUNT?fL0|^L=YcpMUwEZ@)b!5-V-D8pd?H_H)+;LJRA-zg+;B* z(D0IQ^lw;z=E(C3-Or0iD=0`KYHh}BJVnsV6Gc^XOpM0D;?X%YG|NgriOaK^O%?&> zA|XCN#K4h&x-tc)<|6@sV%NQ>B8JnO;3M(XB>>b)N#=D$C@#v1W`<{BX-O-C?^qzX z5(tUXF(8^|6CVb{aY5vl!-0@i8VH9)5|Tt&D~W(#Su4f$iUJ={G+9y48bB8fE8P2 z^b?1}u3Ul@^RZ|w4uB79vKU9MIpX)hU(OwZF-zCMc!xAAPfv*FwKASxjS6u%GG4Rs z{C#*g& z;(3D1fV82&&_mj(B}6oWZzkzP!J;Td;X#4#IjKO=g#ur9(u<-G#VHi!DEdKYEZjwy z4B+!1iqj};D9)fbi^75eUm$`cfSgA$gyJHKcR*<7xDt#+ACPyk=Q4_F6d$0d1ff+L zTZcC;B*RhE@I1bZQV`w(i1bTwxCGek7}&ms|BPmb(-p(ApWek_Y@O6VFBQ0PdAo>V ze#acL=f8G&Hm<*_?c8Bg<7(~5#^|PbQ+`r@z>XZYo_g-v`OQK9#QyvZwe{wPFJVr| zTcEmm=sEkMJLP)8A^J#E~~b-1NQ1+_xYWxyMo$1L+4YH!q#Ke(Rskm9QF*oZLOZOJDy#a>Y3b_ z-dsxrljB>Ts`mB+b`o|8M?r8^F77;pHG1dhxO>~K zt*C16#x?d`{L-9~zbfAo)IM%wE^#wC20QSnHLwHju>aDR-Mh}OPVLVxs{J?i*_y;! zGPphd^i#FYd%)fR&+dU|AJKbjo7eZUWy3@58QNLg9anp%>Ahu-dm4toChB+J`O^n~ z{6OvG=%9zH!;RB{vrsCox+gYf61A{_<^y&D26PNQllG@>sqMEnu5R8;Osf^G2kdQ7 zlr|)r_j@kwf4HDpx$laLYR$i$CBI6)vO#EMJ{b!6X8_BekwRifFa9ACNrN-$kvdiy zp|G5WLZqX12!$08C>@|plhrJ7Oly%=rnOn(SPkUE0b`w@b#YZWBri(}(;jMaJUBD6 zr#;kywl2>@mL{fckaXtT)MWfL_!}260-X5Joi)R_Mq}SuGT8#>g8Oa-ZJVLhsiPYR zLOsxj2G9b9~ z5D`NVY6Q1{x(tt?C@2FE%375kcc`ACqr6D!VYD72nxQpGI`El>1B&{9f(*F?LaS6D zXk=oxtl9MEV029+GHC&mS*=PI$(j@t`6Y;$D?;=^M5{3(X(reQqj3mBRdJd;(KyR1 z>oJIsjx?v?<@hS{nH;cx50~*i3PgO(nTesZJV zNPO5ggCZLXY4D>bKm*-Js72yP>?m%7fP7L4D&!76-=)uC3BslypDiegQOu&CL5qe& z8o91xUw-%^bJ#bJ0(YSQuotkkm=@?no`YXBMx}wTT|GZ0D(!z9P+OODq{qOtblA|n zt?Y=qLu$j+#`tD;!u14@eF^}k^9&$(mD({zvHkoOM4B-jrS7Zl5gpsMPC?lF-pUMK zPF;WqGcd2iq_DmCbVaS}12hCj0KgCSOsdzn&o(57w>zI+K45)sA7o_)-c5A@YWL6S zv2qcB_pVwCz&i&XehZFnne zx?u_mhgwbHNXS6QC>YFYWq`{>;b{(K6(a&|5ijU8n-Ex+`RFqLKonPW@Wqrwj)_4j z5Z2M;1R&OA05Nx+Igr@#|3e~84+PxZH59lFJt+{eg;zqBQG`%Xw7Z8bigs@T#2<@* zcxMC!{+GK=*@YK6%CGym_^IMoq=aXvNc$Q6$9|8=&nK8lu zog99wwttcXYoCBZCx=(|C;Y1Q<2+o$GkY_xX>gFg6gm8#aIG?fGKIL7C536s(s9j^ zgKJsLO55nTmTQwGjy0T<;uKQDH1*8lR9c%`v&@padIMYa>SM0|Y;~~F-?V@k?1>MI ztOv4;HN&PnG()o7X!u3a*u>@f)yW|u9uPyW1@I86gyhBK zXz1uMEZ|YwNL-KIrJ}Gd#Ml!i4uNQlw~OWkPG0}fK9#hVhcuB zq4Wfl7jk@ph^X0h$W5Yb8U9=_7{Pr~7{tx$rIHx7C}q5lEy^1Swqz8v?e;AwtlF( z@T%+F&Jg7IT~}#lEkNa9MYTia;3|TaYnN3!CnzqBCx^CX)Cx#*Ctd+M_UxWjJrg-K zkO{=h!roWC7j}f*MYY$raXm4d?A&Ib_NmS;Adj!IptyhM6Sb4i$)tHm+*7@Kkh3pP zj#>}Qu}!T8=D3hS%-d@FoeX0BjlHAOYdp(YZ-dU$>Am3(5#F%-;XoJ&!s@zS5u^7- z^8s1{qd5W%Z}dv(HPZ@IYj)tE0D)QzD)L|~Dl3qffr@4hT4jz=l&sRioSu*1?bE+j z1(x7%Lc|G=dP$0A0EpxC_D|#on)N-sN_d6*8cg7=U}PNdU9rhz`j+vkjQ3kc`a2{2 zm65(-&V0*UQJE{>Fb&7%(J}l33bkFj%WW_{@Xb-XaC*(zFS>IRTtvfv&@-~ zuKW9ax9o5&{iIk_#rFxN=Euv125oeA5a7 z=%j8qo$q@DB_4nV6J4B`%?R<@+39oq7Vv+UNzIte)FE76!r(yO+=Ig z#TN|7qrMRExl@8HdbHJQGAn-Z@xSMy`34-s>2{C}ZYN3F2j(N3JBcI@q`+o5ck&S@ zNgYTV*oZbCNOz~~$KQYrWS}vcoZ4K#OgplHY%(!78q7>!rkj`fBc9Gq;Ow%T&Q61)TYEQfbZhT2&?5J4 zb{55e>ONCspt|lJlWwtr>UZC3yZau4vz9NF0N?6Md)MWc+V~{~e;=H9se5132Hmd* z%3!3-u;KnANn}6hmszZH;Qyq5z@}er=&!ZEa@%?j8hqVm2Z3Wfu?mBuTfG7}`iWH< zsD5IVfa)h!WuUrll}WeSK=r#<+wNXtaEjcu%p+<{^Vb<@ZQSGP0JWZBy^T|E<1`qY zhWI>&bQbuiN;q zTlk3?^9>vS4GaHNf>lo0_@@oNex9dooE96W#l|^fJFv*jelcZzF^~zS@`B1V5AM%_|i-Hezs$eO(S4v=>32fIM!|? zXmIeX66>01n=@qa^$b32ejIA@}zM{Imz@Gn+pF@0!p8hK`%iFPPA4483ha zzi6PkKTiN^&Guh1IB2OvUzxP=zq~I0j*b7G!PlO05>NXpmealq`qtB)GW3hw``8ZO z1deX^2L`Hp+NS{3?fz8*U2AV|+1C0s3*Q_En6>WN_+Pj16K(ft8~=R^KXLxIZTxQ- z{37=UZ10}|j=uM46FS4tcTDIvP3X56`dJhDZ4>$(hJMb3e%CFUl&V*`fc-Mq}--K#w_<{-jfeHN~Tj3W?=#Nb3j~V(U6Z#VqI?vEA zo6v^_s{71)fa*T;R|dM4&wK@#*7*6Ug>QcE@z=~|Q#OsiF*MfBe}8L2RU*ZS}8tJB%az6M&>=fKB?F7DmD7xe$H+jjCZgRfiiecD3f4clzLSeO5SjsMGa`O`N3qJ^KhyBQn*iG{yf8MLS3Z`%03vhWkTjc?ib zPc8gJ`+VER|Fyy2F|g~bv+Jn#w#Y1*H+&Z~cg3sQZ(gnQ(z$*M)%Sa*x&DF8H4A6C z;+!&rPE!-Lcm+xE{+ccgJZ>>U|`rc5+>~3vbtqVo0Zo!yWv%HMu@;$ zZZg4-!K>^IKJR2koF!NLynv^K1HPMKc;l@r`@FtS;0-cBX@RgL2}6?Du5K14!$jo8 zfHxHM1r&9ob`5j30x{Jq4hvyPQS*Zy!h7&FIUgcHZ`h;28*r~Es|5%Q`y@pqyc`}< zf{GyVBseasg@}Y@kspWm?V}1W$o$Q)5Kw%|#LEAGRn(k-wpm^X`H)Bg0=%HNjD`eR z9uE?)nq}s~;#Tnc3U!-#l!znn9)87(K(-F}0w4fD0Who%tf0@Wa3ChR+UmO6@(pA=T#%i(R^w)?6W$UO9$ZLN6DKr)Od`rb znVkT-QI>YOYM}f$o@gF*rv_N|#2a?mu@vz5(9r1Fzqu5wsnFS|AKwZ`fVkt-*=odSe_@ z1I}R`4h4x~9nm7pthyR^=21QFeE$jGys0}3NFjn}O!V>|aH^3YK~v-#pm-az8K6Ez z^uwtbz;H0=HGEhtG{%P@8xjFeS$F)6vR?^l+i8O@1yt>t?IV*x#&WeweVyti9px2C zR&J=C= z>XspJ1&GaIB0`l3cqUd-5zdPb6HtR_0pIon!%$a}*%a!wI1Hr&>Lc6YWO8l8(b`%y zBM#xWxRYr$r`Ytenj0TpT@zb&R(x3YYwmBX2M6R)frx6hiDH<_JZRV|twy7Q=a_WZ zee?yhlt{t>UQmopXlW7-DlDs8jE`CaW1^UfI}j7G8`HAvAhZ;=?e1j%C2;e$1nW z3*?$dJ(3S13mO_?2w^{ z3SmC+qUY$-sXGxD#)XNsmT5z+%UpnFNG(j5z_iwt`u$g~ zFn9An4duN)^oyY|OI&{8w&rdbk_3fB>kHMb&<>1}V94juyQazQ9!Y@0S2Zg5#==#N zRZUg(we?MPwY7EXMpX4frz~L{9%@K+*Z1GCwul``n7WmvVk1qNZ`%r_t?J)_vnd3+&AF&+p(R4I^r0qBIG@PQ3r z4IZE5i$7ra#Xy*cr0gNS5N=-01XF~<5aX5-e_*Fco8rwVUw|29l#ctKi)w@Ca6wbM;Zd>xFq_FiAkH z1MiGK6`63k|ACsuv+eM_n$Dw7grTG4 z)lEERP6;;(S6MtXctlokhk+mrr1HEs=z$NJyw-$jM6wy2mgJ$xME6a?XE#tbCtIFi zMJ5F(@WFu;GA4L9*@2h%Xi9eB7zeQ-5lp+J2*n;0dr_32*oOiib4dz{11PY*ka83m zC@N4?qNqYqjiLqxKBp0UVjqxcO7c)y`|0>N?eTO3(Jk%i*-Aksl_YdsMcV{kS5+(b9|e&fmW^<(Y1IaD!&;iE$S} zyXxrT{zwz8?2K|drh8{wW(Hzh=Q4~QikzWUT~Ti5^zdx=jD&O59AE56B?1mNZEV4#v2i07hjnY(b$7xgEXQlp)2S`S;@JKH+nFxLiKUDk$sX+2D|XSR91bnZCLarA5? zEqaxwN3Uy!xxG+2_ZBTa6yvVL-J4H4pEXBHo*#=|9;8jzG>SamJvT{<>tozCnDtr8 zlKea)+Ixl8U)9dxG|cUyyJ}+GRhZ)FS?9Cy$jxY%`}vzu-b4LLG<+xKze|t2sZBS& zkTdrwTHGAt-dt`z{p@VyjmXXC7o!8$>5ZFFayxcof;LZTGoPLJ&b8BB4KZ#KW-f1j zG`#43JQiu82RiRvoNAsfndN2<((HZc25{j0X!e>Ob}Hv)m6=O47tXY2x$?*we)FRC zwZ)MLp|#*Q#j|De*>i9LUCV4VOl#rrie~fXxw)M5B-orbE+7n%fcMI`FhwB8{{fMy5hm(E5J*0riQ28vyC%XXx;&I)r!N5`H@DnZw1)bie3YBUFBB= z>?`mW;DW7@#^)C>D4HQC@YIv$y${pr9tA4Yc)-2diI!eKDE8Dfx@<7vZgXWim=fceYk1$>{6xWa5^-*@34UgL* zrO|$#mR(Oc&Fh-qbTPq($x8N*_SYM4MI~S<0prXX43r2IyyCly!cMv=SoU==@k2 z9mkSi2gMpoc+MP_d!R!va5mD#N-s;kbFkMAt+d+;K5MEHxiAMbVlS`JuPkWml`NPX zy3oF*&WQ8rAQp4ajp!JOd1P7x`2x#dA2cu5XddiFSuSs07>_tV`PHO7kENC*zH7V*d*kg7Q2Pk!dY- zSw@5B*1W(r%DkYPRzovrcuc$*O!OF-c}@R%*_nW^lr3dHtw2w$1W(1RznVvSOiw-0 z-kY?PB$RvtJ>RpwNN2R8^Z8(OP@rQZDo@15Ch4I&TE**|SLUwK-OvNx(ac&#E8>qx zr_tD*xMwlq{qy!+3ptU-Kkq17?1m;0FTGG#Mx~EZxj|>jM-HwoReK=Hfds#U7FK=i z;F426+UzK)eU!2wKhA(cy|?Y&+55dyr>9FFxM*e()*9BYG|^Hm#9JN=K!}4eUiGB) zDsOrfzQ6HN@8aplSE0w=-@(#X_OvonNVk<@u(P_u3MZ`q2e3AkZmo^#dVB+v4|F6v zOLVN?WW6YG>k}k+O(|F7dp~$F3J`mX1Yh|Xb5_IG7wq+! z_BIV)AFzK&DB!naWD8D??*vp1zdd5>WN#3(E&MBEAJ^XFogg2=C2mM=2Jv4>PN(xj z$F2_@JO1M6{*$AdI=Vk})c(bBf;vuo=-B>Aijd^&c20Reaj*~n?Mihx3)#H?3$@az AXaE2J diff --git a/backend/models/__pycache__/supplier.cpython-313.pyc b/backend/models/__pycache__/supplier.cpython-313.pyc deleted file mode 100644 index 12c62738b770e49c73b9bfefd30a86b6e03929dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2268 zcmah~O>7fK6dv1a@5X=mb;uuy^9x&O+CowlS_Kj12i7G)Y=~53wAy$#VU_jT%&t>H zi{x8F50z7?Qn-P7fm;xl9Me;C%*yIdMoJOtDYsUk=l0F)VnR_>|Gb&m@0&O8y?O8L zVImPl@cDY|8)HO3=y$q!^$Giv!*5~o9g>k8$RG(}l&14wCLjfIkj+Dxki=n53gfU8 z!4Zkayd+?OVO*w8isGmg!!aq256O`ZB=gziXvjMc*UN%jmuZk1 zaU-w=IhtvbnsGCm$1*Ka3a8R&DJ~`s=ms%B1Wk2x$H?n+K3jB*f~A^bm_%d;8`csL zCIFuUoJ6MzST~lejJ`@ZNuHe{{CJ^Y>Z&D%NaTuIEb1CbT=h^DXh`%`eG)M!LMK~ZM9Fzk%GzfvhTs91DhTzMc3*tzY&k8iQ**ZCV zf{uV@qcjks0Z)_6%K|WCN0@arW*nH&e=!rlO!_)wHD-ObL5_o-MnIdg4HWk|i6fk5 z#%TdgvyUdewv4+2CX^8pM4$P$7^e$hsrTGnhOU*Nn z-|-edRpWPNyRM!0B6d_Vf4X5`mv2$)|Lyn>J;%Vao+EnNn4UDmzg-T>9T3^xEHAhF zr*ziPF8Me^`(ST_%&hP^-8D{+&*^8J9*X;%H08*>cwnhX>?49)Di%#c#|KcJh}7Bs z(w-B8Bv!;mUd5}*GURjEur&P+2^!kn*s|f|Zt0diY-m%oD6EocstYC*fR8AKcJMb< z$XP0@8(z~F)spEDUNsF!aGTUSdcFvmuR|eNi^dXZG&J3Ej72cUepU(WIKo6-t()Sf%`ej!86!bUQreeY&)i5=~e;YgrYb!s2+}EG!iXT z(3VO#`YE=$G3{7&O1n~s( zP6Kube?*!f3c6`$Uj=lyxQgomV1gQJ--BlL75cTMr+no_YyT#%AIsk_Z%w*wC9p-S`pQ&n#K zPXV16*__xOaEGSL(`)mUOB-|RGj5{4%1r|{cxH28n{x*y%d?gFjfwSam76@eWtU4; zRyMTtFDX5OdQLx{-nza6hs)*5YbzD)J^(TRCq|x5ZJm9V*@1Ir%b7~+MrcEO5Oo{j zoY_6`!>21-xo2PO%(>#V@=RrF$^Vs7sobMLa7=&o{ednZRWKij$Mp1e`Mw6<88 z+Yr|kH#u14ZtRU-+H?G% zPS7nhHwagPC9{I?e*Ix;3`xFObj&oCY@QDvGlidcg*S#LU`ai)M?m}$3IqcC==eVB pe2&I{LgUZTn2W~tQTxkCI$#CXoR^4>e_aZoK*v7n`VEOp>pvC?RKWlM diff --git a/backend/models/__pycache__/user.cpython-313.pyc b/backend/models/__pycache__/user.cpython-313.pyc deleted file mode 100644 index 87d6f459d1cde8f978cd527781e63363bd5f6655..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4538 zcmb`KO>h&*6@W+5SdxDv%a$$6fAWv~gD@Dg$OeNkwy_P27NhkT2({Xp2x&00$TOlx zGHZ5I+biy zdHp-DpWX!m4IDhiRb82H;kci$QG4pF$~(@*ao=(xC%RIcz!9z=>+Y0Ga1*!SAs&Gz zyiiB#nBJ4}3O?c!{KPNRlX|A(Qw>5RY3%2s+#n~`b#bCM(>Uod{gWo9^?}y!&<4bM zu^|-{nn^SGs23YkA)$q|uzFLfRS1)?&_>!=JCJG@A|%44tYMLPw3ud_30X`VV?t=Rm=>F9wU~Csv^tou&9qre zgfVRnrrl;D7Sq9)h=b{{nJ6$mv2)r(q8XnUwZ_tA+q=QuBld#5lX>ZOc!}9mpG|c! zs?S07+th$fbu((fK@Hke+@^XM6?afWHZ^Qhy^I=mP$M=qYEv;rjXJ1fHkGicK1L-R z)Nz{{v#EYYjX9_jHg(db1{ihHL5 zl1Pb@WO#ilahCd0N-1|=R^R$SOZ^YbmcI7fO&g0u*~pSD=>dGJb&8sozoKp>|Mp)% zE(XblS8kZ^~*}UQ$bBi#8LpCCSiK zz?=U{IxQ1L%hQJSqEuv6oi;5Be@BdWrgV%R)md@}s+&_Tb8= z^SPW>R!fgtvxUNm{=8g}OY%vL6z@aGs=c4f>bY!QreW25OIj~TS@S)ehAmf%S*5UL z4V$*t$UJH#Yy7m`$==B2%Ehdj+oByG(wc1uTD-WW=04TdX-f^%K_L~i^`q`rHErWE zEcLnu3uZecpp|l_8W#@MRo8R~$qz7M2MM+$TnJn*+RUtMB4R09(rJqcn3K?%D#Vx@ zMOlScu4-jM!zQGeD(ODxY8}eDzQ zV#_`OtxFrQvT35_WL<}m;Oex?sFRAelC98lZY427{gQ;|SCVLhByDK55z9K}f#r%;?maR$XCinA!D zQCvWA5yd4G^C&KZpzV@`d#2>z3x)6IE-W;Ta1m_p;Gd&GSO~eGXYkt`FK&?lD4D=G zq5s9ralhw&Y>RDQ|63%!&%anSBJ)`Z7=ss$?%5i->{|CaLG9UBhR*~=U2dE(!0G8=P3mV<0jJAoIzw^sLb?zmq7e52) z{87J2BQ|MtOx3twyEc>a&R!?IAMQ_WZ08n%vS)h^dTp!R+g+;;eq!{VF(Szt_v^gP zyRL(~p1nKOxiy%?ykkEu7=trL_r-tW&XIKOr01YwKX~l%c~^uy@&A%%dLYO0K(5LE zLazC7R*E@h0B-YIR%+&$|CzsfST-7TkWCmfSf<+yJqD~?wFTo3d6bu*w2%8`PHZCq!9Q9+qViK}#zLTE~JuX~%k? znAP=18p%udv-*7&g-H|*^@kqx zkUJ=Fh1ktOu4C=!+F;|kfjS(Nd6YlH8ZI9bI7s~`5E#C@e<^%-j(oSdm;3(f7s*${ z)!UyNof#IA<4-PN#2pxazPX?K;p>+-je*qm^~%(4{3#exM^S&#h|V8H{rSV#*xr>F zYesCr4Bt<_!K4$6dlj`psex3pW{pf{d7|V_4LYF;wB%B@-w< zL6HQJ;K>x$&!ISvVg|J?GHVudX0ZUpO5&J#t2gKm3)uoB*Be{rPP1)eyNLTsgGcsp zjcl;Y!8|LN0Qz+bruw(b80He{Pw diff --git a/backend/ocr/__pycache__/__init__.cpython-313.pyc b/backend/ocr/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 5d77db6a6100c9ef2f92a4cad26a4f261748fe1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 262 zcmey&%ge<81eg9~W=;pvk3k$5V1hC}s{k2O8G;#t8NC_27>gJcKx`&&CNJh9W+0n6 zm_?tdh$Wp>lkFuVP^l*4EzyFa{N&W);`q$Gvi!{C)cDNY#Pn34gji}tNl{{Q30xvA zGc_lrxQH33uZRUm_-V4<;)E*7F9M6-Vk<~2Do!l|%im&;k5A0WiH~2&@ENF_;TC^> za#2h?(7eo&_;|g7%3B;Zx%nxjIjMF<+(3DdgNns~#0O?ZM#j4gI(Heg9&jsNP_end MW!uPJ#0iuF06L;XBLDyZ diff --git a/backend/ocr/__pycache__/azure_extractor.cpython-313.pyc b/backend/ocr/__pycache__/azure_extractor.cpython-313.pyc deleted file mode 100644 index 48bbf6f3e12bb6b7025791fd923fb97cde947673..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36213 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 diff --git a/backend/services/__pycache__/__init__.cpython-313.pyc b/backend/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 080d4cd86dfcc4600d8f9ba1edca700c65690024..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 249 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 diff --git a/backend/services/__pycache__/brakes_scraper.cpython-313.pyc b/backend/services/__pycache__/brakes_scraper.cpython-313.pyc deleted file mode 100644 index 69cd31be199849b0cdb84021a75cb29054a7e83e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7267 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 k9auq7%$iSv!4>oeq+yDRo diff --git a/backend/services/__pycache__/dispute_archival_service.cpython-313.pyc b/backend/services/__pycache__/dispute_archival_service.cpython-313.pyc deleted file mode 100644 index 27555df446d32ad88c43976cb4273636018f4a5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21247 zcmeHvdvIIVncuy5^AZF=yhwmA@GZW@heU~@C{cWhlt2lWkYtfI1d$*`n*^l`(vs-4 zopsU<-EM0si6d!~t>mN~GaXOEZL}>TmExHkvr|;65;K zQY^($EX}G0X^tkgic=9=&8dm4;WS{Y2ekuQPV2$2nnB%wp3@H)I0Jdt4rUB697A~B zVCI04GZJ1um^EPHOao@lJYeB0#LY06Jz(XmgwGhX4cIw5;h90lfRl3)K65ZR(2)ga!cA)M05*7JOzJI( z8sjeys-c75q(&(iloSp~7>p_yi0V--s~Od`XjrYAdS1inS>32%G(!u~T2!o_xG}6@ zG!xu2Ayfu&F|rJ}WR7OBMzFKuz6=f*NW;XMN6oBd)WT+ikCpglkAmHzBIz=$jfAtY z_EEbMPB)qy#=tsA7zgVdbwb|Bei}B1_~o#I01Po z1>!#S=P!$fKwv67H5UkoMd|BZF9#@vbR#&dzCvwS?SG=*aF_iH^8OT+JJ*KhuNz-$ighzSimNY@pxGr9101@46hoi<)q7CNvgbMj!}9;i8Eh{$K$J zESj`0dC-6x8dl||{IpGJX1RO9Dps8?ykC`W5XqgCi0xOgAn!C(8C9iQC}D#z@TV4} zl?n?=*0o4*A|$UYBDG~mW4W8*dGH(%c}Ux+{!)sX%}!S?xdb~PJeqMB%zlu=`+yHZ ztN7_5P!(RJ-UM~vXgNh?!Zevwk>R9sB5UOZUkGG z7@r~uWf#y()5t=is%Gp#;EZKsoeK4g*YE!@|E| z`e2lE&^MlO(0RBiP}yEgnZt?}rRblaa`w)0Gr_qw&r8^^FW0>!_cpLn9|LnP7&-TH z{Y#*zzRdJo3eJF1*yd^4*|fWEXJcJcQ{5ph_(CXBH!i8Hb-U_WNnY!|sy7F=Fr-e}EPf^M}($quVpW;~Iu-&T|(GZ#ACQu3GP_T%q*@&n) zADlY}9yoEJAfk49_F{+=b)oRs?087jV;QHX&Wh@p@m*rZxzMHYsmV}eu8NW77&a)k z8%ZmYJ(w&}bpw!9(iq2CD5^0722%tr0JM0KiJY6|<^t#v!8H>UiMWwwYGD9n47PqC zsr8jPA@164);=*BmNfDgKwt*DDSPg<{a5$jaz|~oi-wKd{8(;-klV1F70vBfiL9#l z+^)r}hdRof{l?_$lQC1FU@GK`c1BH&%f*7JC1z?9Ol?t9d(70ks#{cpNN`nLdG*%Z zt%;=*Oa1(=UcRM|cl5^`LxN){>Nvj0+;ik#8~OSOsS%S{?p%I;d33dO)xn$lctf8gJ@oNGAsSJ%2F7N?bFh^W4YHsW zGl=`#<@2E}k^|L&ufp@-D=-74MWv7$0SC$Wk{3_eEm`u!-4Ytmh$0ix>pk*pObSQ) z#Ily|)#*-+IY#&2M?vbzeUREEzb`z36@O0EM) zQXT%uad0>8H?*kAsA8&?Qe-X&g$Zb_B9Ocu%4^-PE}|yrVrm4JBVzPauz{k&%m{7^ zi18K4T*3w!$zXkxayKOPE+f0Qs3NK&C|Q{4WXj_91U*4X`4Y7wW8n_N20@<2N3$m( z;5ac(69l@Hr(8@kpKoZJu7-lApZ{n^eUU*Vs1CP<&M-XaPDY z{sEjRZJwitkM?+wWf&jXWW+Px5t$zwgUxv@i5Wnd=>b$C#BsA+6*K&O*kXq+O-1Gc zvoCP%&{W)hAfoExS?@w=ZxD(zj*vtoeN~~rRTl0bM(aef6XWJ|A7+pFhkF8@!^53j z{YQJe!)&1YuuoKjV3m}3)zr9Xn1Ll?e2R;3hcK3AejXgPNLxhB?D-JrL>D=b>|#dG zrLhpf8${jo?BpcKc&$|YOi7XtVb;fx97lqCCowbL-~q^i8$cKHe0b`@d?+D;B{@SD z5qd(TLlIp6v9IDr6685pbx4I^H-SVii}l@-Tj$p8Z~Zpmv3!x=06}GOv%;ED`fGRo z&B_~ zm&QLaHiJ~RIj&V+t&CYq1Z&ByJyC1z(s9As5VP)G-o4m$uekhe_jldj%fDq>iY$fr z!j{FMd%5nL`WyP2#v8_4BhlPLi-#cpYOp><=|hTZj@ z=Ia-}z5B|6pBqblyQzh6e;+vt)qaic?x4TcW$so{w{6z0M)mFTOyqZ>`|U=i>wy0D zK0UbHQ8C^7)OUBN3~m`Y+l{*sW~aowlQgVCQ(>i=HCokJLdNOjPt z&yP=VC*a+?ke?#glPZD&-OL@tXbFLC>GJh-A07^PhlYEIBZ1up*_oG4vZp5<)ToB&~jJA!0TO)fFqBB#)B< zUc@B-1_%+vB`Am;IoD2JJ$Y^P>gak&(+|5=G(R!_*c@%?k2(%7_R5l1w`BjsSpP*O z?}=#c{x47Rwyx025#F_L-Pry_$y-f-?a;oidg{Fc)~*8e?d(k8Z|Bq9)%5KGrmIGO zyHbxX^-OoA`u3i(?h^GK@U2weDbPZgJ0)7=E7ib@OkxS#f>$Z(`5{oNWy~kt=3W*- zk3onkze-m4f}r>UG>}Fh$sxvR0C`mj_!B~>+4h600eU*cfNL6k`#!AkYqr!<{|D8vLQeUKbyPy7t)s>#7er1B>@TH` zTEA9Knv7Nhn-SLewZ7`4jN+^uF8DHx`#+?&YvnYb*b+*pv@MagWM4{4=>2-7hA@}> zdSAU<-h>URNTd9getxiwta!lI5*3VQIefxaMpy<~wd>Q_K@etMcKl zH^s;`2*PLps7!&Pe5e+t7}%OTaZQ4mfyyH>zGq}EVYANk@v+V1F$3UEu3wEUXrTJk z-baMy09ARH2(8#2^y#C$|5but;2`mT!4rk&6@Y+H0VM`D0>`V!-}$t@8Fm`J`bo`{{umjF!e zg++jm@KT6^7bRm%Dc3|2KvP0JYx8s}pgUqX(y8|L_z)b4N!LzNK_WOv?_F>t&I;fX zg$K^W&H^@sKDk?cU{f81cvU9JHGUH$;VSC=;TOWQ7sH~8JG)?r<27~U_~21?L;PZiCmt^%(hZMP z<!v|aG@fxBa8Qv3>YRQ+vcfi9*x+J&r!ARudEH^GX2nZR!OccQC;O(BoHW1EK5F(67 z8#4qJxB?`o^K+<5b9i*ap;!=^c+cKaD&MNJN$&MM- z6LMfB+S8sZWh82?xf}`tR5}9(Ea$kdV}h#in5d8A!P3?mcOf#~nQTkgr#s#R>EO@C zZMNInNBIuq75N0ZlWe^0l)EtIsuo<;QCDpYPq2Pv&bfXmRtEXu8wd#lA>K9-GfxWU$qn1?`|wEZr-ppZEA+;jMDpgMK@b-wB9^$c}_2tq!MYu@@lGoPwCML2x!iosElqzp}YEa+*^T zy!Z`s%u+5`%K!D`+t0uC{BmEcrB{Ieir%QD?{}vLc@$JE9^Ej*^^p?z-zX}*dGW@@ zo3Gw@^`71Rojp-|SR!)oTt2>0538Y z-=<9iWiO1`ssvkA%+~SFsUHOXZ6I3LvDm$lQ*=xJHuDy9{g2?F?{uuLPpIqTYx{YG zOEyX?W2L=9Y43)mV8deHusAm??wF-au#`Q>(3Y8aqi54Xxl3ZMU4m;@%yop{dnA_2 z3b`yld@`DQYVq)fEr*HT-K6=b z1@r!x=~NwSp?ISjUU;(#Lh&{lc_*PRLi3qpHF~}x1KoELmnK46nByKb-;sMfPc1mL z;49>5!A0<3YCvZq2>05f|crj~>^1rcS@`jmZXqKIMjNR|ASe#gcdWXuFm7M)+`tC7PeYPx_jTnb)f_uG965|yakNxk3Z zH$M?CasWPUPlq+hJ&*=3a>&w!;6-jWk6a0q@S^-Jcv0uHf{dr1fe>1mxg%b= zE%Iv=MCnfmiuA1~phzn3aRo*C91x^m!E*_49z3liCXOOG6S$I3!EfX+fCm=(om3}{s|It2EK?~3f@8x6VyZq-XdWvrTB_-hM?m5O`> z6@c6EXW=GQR@^N(1Blz4sOLiGr-Nf5t{W3Wxy^dUn%tFa>sKM%bY!W~&x#)d)4@U3>ZJ%ePKPb9OHFZ{&Mo`OQLp^YYPX{-M?8 z)kA!KAC$SEI9AXi6tpal#CEa5E;d>)%==IACr&A+W`n|FGE}^~)-D>Vt)W1FZ-PveGE54TgUT{c!jIP^T6gcF_+DPo^S>fne{>T{b z8DF=Dw!k4F0*92uL6GKOWH&wftV01ooL?9OX&64=L6CKj6tCHj)c^?M zEkO_jSO7tG3^h=9%(S;$bH}nD`1iB2y*t$J7xt9F%gA01TlF6`Wgy>9Tsn|` zOff^X>W`V+p-T0~C0cZ;)S^o*rtonCb8LtD%kR}Sn@S|(A2;w)v$CM=!L;E6g7e?HZi{VS+@L+whzw{QVgVMNO6K!c{icx zp-qr>rW`JP99AXo-(Wur9}B@A;|U)sqD}?hzQLay%L8A*ZBsujso#CrYtuel5>t#) z%8wxg<;za-hSwzhTeIbJO1M9x#D;iqo5uQhY&itEU7}06im6VAen1+zkEa2y?#EqO zy|Rl!)S=+<5b0|y_Ydrkw$x`H`v3e1NU7-Yl(uUF4$}`#ppAe=9qN4`H`33C|Gd^vVtmb`1MLr`M0j8B^&H z6P6*LW+k768%9dyvitRN%RRZgOaxx zhrLAIh6Ch?1KgC7kVDe=sit%si$QR9k#8fEW8j67gw_q8?`}pZdh)On%n-8`^m*0U;N1{KYnHH`Iz^t;5{2VFeV%ry9Z|t)iHOy;I3cR ztUGqCWP=8FtN87Tw<^B3gU_phVx<=uDqqsa)+$0n? zMT=YDAPSD4;N<91tg;jSH}cwl?I_={=SydNglQ1$4M~g>j%%FOD=IjysjiJx?G>u_ zu2jd`SfP!LRt?8WhXsr0ihjeHd+prSbKiJAX7TVA4<6J&AuF#R;O*7-^NWADuv+<_ zn`5m*LhI12*0(#}>WG#$^Lfwm_GfwjsKng2)qUOwRr$GN*CRMG$=~&vlPdGme`WFf z?L%w)bB9{xbBFya!4;Nwb*>u^DSE)o{Wtc1`v7mN`MI%%eDzTK_mO`Gz4;p5r=!2t zY42&L-p#f4l&jyZ&Op8y-QR6#0sg%mbkAScQD43i)!Tr&<4gg8^Ok&`8?|ZD;z_>JM~Hy&dWg z_Rz@h)k63WI<&ywRjYwdztCR?;rsv4LjRZc!4p)OgbJV=DO1vktIKk$kn8X|U#0Ayu;m<|PL+X6bQdILW_xu9 z!oyCq~ zosTEgNehmH@w7iPC)nnocK*!YFx-KFpv_)K8&K)aOK<2>E7of48O60WY^RtbIGw zxAV~Bb{TryE^h(;j*IRCJ;w*RJ_M?(5KekZ(f18UuY$ zQvv+lY~=4+Rmj^_$UB+7a{XP80bO=5eXaVtO_=4~b{c61(lv@XtXHowO@~$LwRRf$ z{aT3oQDZgr$vJ3hBTSUByGl5H4?n}NG@NBTJq^2 zhscVkIXgQ$%}t^oq8wZpi2=!5NHUQ80LhP#{3jryBLK)a-1r>BjRrS$7Jhpv5(s>s zB0twawjms@kA!%DiOj-W{HN4kX}UKxDsB1o_Du?`W%eOjn+C16=2rhwKq%Y)kOF?w zq}RG`MK&q0mLd<)+O+0q`|0~R?oBN+0Ee1$HuZ!xP}%m)48npQUbLA>SR=(`J@&c+nKchNy!d0>Bp>5@Z;O z&d7!7;57WW-pu8C*!0&2@kjfnW~Cv=Bs|HTUeN?YH#8lokKg5$1`ruLcyUQI$Gw%? zt2js#uJWx_JgHKWieCVht^>Q}pIoa?+^3B@Nf&f8U@bU8zV+w+66J%%6>_e{E}+DpJ9n*xIf8oU*8kWDEkWDrOa#Kshka44NqA<*NyAw N+TXy>RMAq;{2z!fT~q)7 diff --git a/backend/services/__pycache__/duplicate_detector.cpython-313.pyc b/backend/services/__pycache__/duplicate_detector.cpython-313.pyc deleted file mode 100644 index 15d0096d06aae2fd861c28acef1e1a0f30846f81..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10706 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 diff --git a/backend/services/__pycache__/file_archival_service.cpython-313.pyc b/backend/services/__pycache__/file_archival_service.cpython-313.pyc deleted file mode 100644 index 0849cd99e3aaf0d3c6c89d4792e3c083a2d435f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18181 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 Q#iWw9+=19nS{VEP1Fgs@1^@s6 diff --git a/backend/services/__pycache__/kds_graphql.cpython-313.pyc b/backend/services/__pycache__/kds_graphql.cpython-313.pyc deleted file mode 100644 index bc281a45d411c5aed136b287fe4e385bdcbc18a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14726 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 n4lFedV5TZkcD9F}xb_#5(akANdV=lac-(k+++_ diff --git a/backend/services/__pycache__/llm_service.cpython-313.pyc b/backend/services/__pycache__/llm_service.cpython-313.pyc deleted file mode 100644 index 66f8164bb9f76f6ccd8a371366c07985abb36473..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58622 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 PoG5Vqmz&*V6U+Ys)ts|f diff --git a/backend/services/__pycache__/newbook_api.cpython-313.pyc b/backend/services/__pycache__/newbook_api.cpython-313.pyc deleted file mode 100644 index 0ca2ae010a392784ac569f06db8e842b0eba8c90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41175 zcmcJ&3s_v&ohNwf1@+=xK=Ho31ci7CkPxySNCJV7kVH`u5=giNC4qzrd<$d=$4(~c z>Bir5Z&3GUEOmOuc#`>yH{LPcct7LWd>h=IS>x_>=Tn zi1^Dh6Su8Dr*;jvbP?l(ch2Y1N6e>YskYwfh@s1W8wsgLedAL%yweM1�Ko6w!O< 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¶qUgA_X}+!Tp?k&7lj7b1 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 Z(bX(XVNK|2KF=OY*X4i4QOL^t{{fwu-b?@h diff --git a/backend/services/__pycache__/newbook_sync.cpython-313.pyc b/backend/services/__pycache__/newbook_sync.cpython-313.pyc deleted file mode 100644 index ca937640f336e4d950f3514de90d66db9dc0c54d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35457 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 YOBZ&n8SB2K`zPIymS%oOVM=iSU)fAVx&QzG diff --git a/backend/services/__pycache__/pdf_highlighter.cpython-313.pyc b/backend/services/__pycache__/pdf_highlighter.cpython-313.pyc deleted file mode 100644 index c0edf1a37d9eb8ce3ed61aa20b0f5bee66c6b7d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37242 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 Sbqn2Dxg5*S@^Hsm`u_p>4SDbY diff --git a/backend/services/__pycache__/pdf_rotation.cpython-313.pyc b/backend/services/__pycache__/pdf_rotation.cpython-313.pyc deleted file mode 100644 index 31c95c3657922bf50cf270f7a86fa4543305308e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12156 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 diff --git a/backend/services/__pycache__/po_matching.cpython-313.pyc b/backend/services/__pycache__/po_matching.cpython-313.pyc deleted file mode 100644 index 194539aa05f8fe70144f79cbac97375eefd81055..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6690 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⋙gt;;81_Mf`x{-htEd`8t; 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 diff --git a/backend/services/__pycache__/resos_stats.cpython-313.pyc b/backend/services/__pycache__/resos_stats.cpython-313.pyc deleted file mode 100644 index d517b17befc59fe78f6cf9a9eba53cd5f5b00441..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44280 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 diff --git a/backend/services/__pycache__/sambapos_api.cpython-313.pyc b/backend/services/__pycache__/sambapos_api.cpython-313.pyc deleted file mode 100644 index cae84ba2ece8627e9b3245ad39da309ca02ae0fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53340 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 diff --git a/backend/services/__pycache__/signalr_listener.cpython-313.pyc b/backend/services/__pycache__/signalr_listener.cpython-313.pyc deleted file mode 100644 index 9e57e9f933bb7f9c3b0638296103f0ddf0720738..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16694 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 diff --git a/backend/services/__pycache__/stock_history.cpython-313.pyc b/backend/services/__pycache__/stock_history.cpython-313.pyc deleted file mode 100644 index 5ff99e94d019046a355739dccf14140da49186bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7131 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