From 3d72ccf7d9c8b4cb108dac43d97aa1c44d9bdb49 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:39:29 +0000 Subject: [PATCH 1/2] feat: Add i18n support for backend and frontend - Updated backend `messages.py` with translation keys for all supported languages (en, es, ca, de, zh-CN, hi). - Implemented `language_service.py` to detect requested language from `Accept-Language` header or query param. - Updated backend API routes (`main.py`, `inventory_routes.py`) to return localized error messages. - Updated frontend translation files (`front/public/i18n/*.json`) with missing keys for inventory, units, and categories. - Updated Angular components (`products.component.ts`, `inventory-items.component.ts`) to use localized strings for dynamic content and errors. --- back/app/inventory_routes.py | 249 +++++-- back/app/language_service.py | 37 + back/app/main.py | 672 ++++++++++++------ back/app/messages.py | 176 ++++- front/public/i18n/ca.json | 25 +- front/public/i18n/de.json | 13 +- front/public/i18n/en.json | 7 +- front/public/i18n/es.json | 9 +- front/public/i18n/hi.json | 15 +- front/public/i18n/zh-CN.json | 17 +- .../inventory-items.component.ts | 16 +- front/src/app/products/products.component.ts | 29 +- 12 files changed, 906 insertions(+), 359 deletions(-) diff --git a/back/app/inventory_routes.py b/back/app/inventory_routes.py index 2f20dc58..04a07e87 100644 --- a/back/app/inventory_routes.py +++ b/back/app/inventory_routes.py @@ -21,6 +21,8 @@ from .db import get_session from .security import get_current_user from . import models +from .messages import get_message +from .language_service import get_requested_language from .inventory_models import ( InventoryBatch, InventoryCategory, @@ -124,6 +126,7 @@ def create_inventory_item( item_create: InventoryItemCreate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Create a new inventory item""" # Check for duplicate SKU @@ -133,10 +136,13 @@ def create_inventory_item( .where(InventoryItem.sku == item_create.sku) .where(InventoryItem.is_deleted == False) ).first() - + if existing: - raise HTTPException(status_code=400, detail=f"SKU '{item_create.sku}' already exists") - + raise HTTPException( + status_code=400, + detail=get_message("sku_exists", lang, sku=item_create.sku), + ) + item = InventoryItem( tenant_id=current_user.tenant_id, **item_create.model_dump() @@ -169,13 +175,16 @@ def get_inventory_item( item_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Get a single inventory item with details""" item = session.get(InventoryItem, item_id) - + if not item or item.tenant_id != current_user.tenant_id or item.is_deleted: - raise HTTPException(status_code=404, detail="Inventory item not found") - + raise HTTPException( + status_code=404, detail=get_message("inventory_item_not_found", lang) + ) + return InventoryItemResponse( id=item.id, sku=item.sku, @@ -201,13 +210,16 @@ def update_inventory_item( item_update: InventoryItemUpdate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Update an inventory item""" item = session.get(InventoryItem, item_id) - + if not item or item.tenant_id != current_user.tenant_id or item.is_deleted: - raise HTTPException(status_code=404, detail="Inventory item not found") - + raise HTTPException( + status_code=404, detail=get_message("inventory_item_not_found", lang) + ) + # Check for duplicate SKU if changing if item_update.sku and item_update.sku != item.sku: existing = session.exec( @@ -217,9 +229,12 @@ def update_inventory_item( .where(InventoryItem.is_deleted == False) .where(InventoryItem.id != item_id) ).first() - + if existing: - raise HTTPException(status_code=400, detail=f"SKU '{item_update.sku}' already exists") + raise HTTPException( + status_code=400, + detail=get_message("sku_exists", lang, sku=item_update.sku), + ) # Apply updates update_data = item_update.model_dump(exclude_unset=True) @@ -255,13 +270,16 @@ def delete_inventory_item( item_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Soft delete an inventory item""" item = session.get(InventoryItem, item_id) - + if not item or item.tenant_id != current_user.tenant_id or item.is_deleted: - raise HTTPException(status_code=404, detail="Inventory item not found") - + raise HTTPException( + status_code=404, detail=get_message("inventory_item_not_found", lang) + ) + item.is_deleted = True item.is_active = False item.updated_at = datetime.now(timezone.utc) @@ -277,23 +295,30 @@ def adjust_inventory_stock( adjustment: StockAdjustment, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Manual stock adjustment (add, subtract, or waste)""" item = session.get(InventoryItem, item_id) - + if not item or item.tenant_id != current_user.tenant_id or item.is_deleted: - raise HTTPException(status_code=404, detail="Inventory item not found") - + raise HTTPException( + status_code=404, detail=get_message("inventory_item_not_found", lang) + ) + # Validate adjustment type valid_types = [ TransactionType.adjustment_add, TransactionType.adjustment_subtract, - TransactionType.waste + TransactionType.waste, ] if adjustment.adjustment_type not in valid_types: raise HTTPException( status_code=400, - detail=f"Invalid adjustment type. Must be one of: {[t.value for t in valid_types]}" + detail=get_message( + "invalid_adjustment_type", + lang, + allowed=[t.value for t in valid_types], + ), ) transaction = adjust_stock( @@ -344,6 +369,7 @@ def create_supplier( supplier_create: SupplierCreate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Create a new supplier""" supplier = Supplier( @@ -361,13 +387,20 @@ def get_supplier( supplier_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Get a single supplier""" supplier = session.get(Supplier, supplier_id) - - if not supplier or supplier.tenant_id != current_user.tenant_id or supplier.is_deleted: - raise HTTPException(status_code=404, detail="Supplier not found") - + + if ( + not supplier + or supplier.tenant_id != current_user.tenant_id + or supplier.is_deleted + ): + raise HTTPException( + status_code=404, detail=get_message("supplier_not_found", lang) + ) + return supplier @@ -377,12 +410,19 @@ def update_supplier( supplier_update: SupplierUpdate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Update a supplier""" supplier = session.get(Supplier, supplier_id) - - if not supplier or supplier.tenant_id != current_user.tenant_id or supplier.is_deleted: - raise HTTPException(status_code=404, detail="Supplier not found") + + if ( + not supplier + or supplier.tenant_id != current_user.tenant_id + or supplier.is_deleted + ): + raise HTTPException( + status_code=404, detail=get_message("supplier_not_found", lang) + ) update_data = supplier_update.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -400,12 +440,19 @@ def delete_supplier( supplier_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Soft delete a supplier""" supplier = session.get(Supplier, supplier_id) - - if not supplier or supplier.tenant_id != current_user.tenant_id or supplier.is_deleted: - raise HTTPException(status_code=404, detail="Supplier not found") + + if ( + not supplier + or supplier.tenant_id != current_user.tenant_id + or supplier.is_deleted + ): + raise HTTPException( + status_code=404, detail=get_message("supplier_not_found", lang) + ) supplier.is_deleted = True supplier.is_active = False @@ -472,12 +519,19 @@ def create_purchase_order( po_create: PurchaseOrderCreate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Create a new purchase order""" # Validate supplier supplier = session.get(Supplier, po_create.supplier_id) - if not supplier or supplier.tenant_id != current_user.tenant_id or supplier.is_deleted: - raise HTTPException(status_code=404, detail="Supplier not found") + if ( + not supplier + or supplier.tenant_id != current_user.tenant_id + or supplier.is_deleted + ): + raise HTTPException( + status_code=404, detail=get_message("supplier_not_found", lang) + ) # Generate PO number order_number = generate_po_number(session, current_user.tenant_id) @@ -502,7 +556,11 @@ def create_purchase_order( if not inv_item or inv_item.tenant_id != current_user.tenant_id: raise HTTPException( status_code=404, - detail=f"Inventory item {item_data.inventory_item_id} not found" + detail=get_message( + "inventory_item_id_not_found", + lang, + id=item_data.inventory_item_id, + ), ) line_total = int(item_data.quantity_ordered * item_data.unit_cost_cents) @@ -538,12 +596,15 @@ def get_purchase_order( po_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Get a purchase order with full details""" po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) supplier = session.get(Supplier, po.supplier_id) @@ -591,17 +652,20 @@ def update_purchase_order( po_update: PurchaseOrderUpdate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Update a purchase order (only while in draft status)""" po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") - + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) + if po.status != PurchaseOrderStatus.draft: raise HTTPException( status_code=400, - detail="Can only update purchase orders in draft status" + detail=get_message("po_update_draft_only", lang), ) update_data = po_update.model_dump(exclude_unset=True) @@ -622,27 +686,48 @@ def update_purchase_order_status( new_status: PurchaseOrderStatus, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Change purchase order status""" po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") - + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) + # Validate status transitions valid_transitions = { - PurchaseOrderStatus.draft: [PurchaseOrderStatus.submitted, PurchaseOrderStatus.cancelled], - PurchaseOrderStatus.submitted: [PurchaseOrderStatus.approved, PurchaseOrderStatus.cancelled], - PurchaseOrderStatus.approved: [PurchaseOrderStatus.partially_received, PurchaseOrderStatus.received, PurchaseOrderStatus.cancelled], - PurchaseOrderStatus.partially_received: [PurchaseOrderStatus.received, PurchaseOrderStatus.cancelled], + PurchaseOrderStatus.draft: [ + PurchaseOrderStatus.submitted, + PurchaseOrderStatus.cancelled, + ], + PurchaseOrderStatus.submitted: [ + PurchaseOrderStatus.approved, + PurchaseOrderStatus.cancelled, + ], + PurchaseOrderStatus.approved: [ + PurchaseOrderStatus.partially_received, + PurchaseOrderStatus.received, + PurchaseOrderStatus.cancelled, + ], + PurchaseOrderStatus.partially_received: [ + PurchaseOrderStatus.received, + PurchaseOrderStatus.cancelled, + ], PurchaseOrderStatus.received: [], # Terminal state PurchaseOrderStatus.cancelled: [], # Terminal state } - + if new_status not in valid_transitions.get(po.status, []): raise HTTPException( status_code=400, - detail=f"Cannot transition from {po.status.value} to {new_status.value}" + detail=get_message( + "po_invalid_transition", + lang, + from_status=po.status.value, + to_status=new_status.value, + ), ) po.status = new_status @@ -659,18 +744,24 @@ def receive_purchase_order( receive_input: ReceiveGoodsInput, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Receive goods against a purchase order (GRN)""" po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") - + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) + # Must be approved or partially received to receive goods - if po.status not in [PurchaseOrderStatus.approved, PurchaseOrderStatus.partially_received]: + if po.status not in [ + PurchaseOrderStatus.approved, + PurchaseOrderStatus.partially_received, + ]: raise HTTPException( status_code=400, - detail=f"Cannot receive goods for order in {po.status.value} status" + detail=get_message("po_receive_invalid_status", lang, status=po.status.value), ) # Convert input to dict format for service @@ -705,17 +796,23 @@ def cancel_purchase_order( po_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Cancel a purchase order (only if not yet received)""" po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") - - if po.status in [PurchaseOrderStatus.received, PurchaseOrderStatus.partially_received]: + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) + + if po.status in [ + PurchaseOrderStatus.received, + PurchaseOrderStatus.partially_received, + ]: raise HTTPException( status_code=400, - detail="Cannot cancel a purchase order that has received goods" + detail=get_message("po_cancel_received", lang), ) po.status = PurchaseOrderStatus.cancelled @@ -731,14 +828,17 @@ def get_purchase_order_pdf( po_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Generate a professional PDF for a purchase order""" from .pdf_generator import generate_purchase_order_pdf - + po = session.get(PurchaseOrder, po_id) - + if not po or po.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Purchase order not found") + raise HTTPException( + status_code=404, detail=get_message("purchase_order_not_found", lang) + ) try: # Get supplier details @@ -802,12 +902,16 @@ def get_purchase_order_pdf( media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="{filename}"', - } + }, ) except Exception as e: import traceback + traceback.print_exc() - raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=get_message("pdf_generation_failed", lang, error=str(e)), + ) # ============ PRODUCT RECIPES ============ @@ -817,12 +921,15 @@ def get_product_recipe( product_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Get recipe (BOM) for a product""" # Verify product exists and belongs to tenant product = session.get(models.Product, product_id) if not product or product.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) # Get recipe items statement = ( @@ -860,12 +967,15 @@ def update_product_recipe( recipe_update: ProductRecipeUpdate, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Replace entire recipe for a product""" # Verify product exists and belongs to tenant product = session.get(models.Product, product_id) if not product or product.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) # Delete existing recipe items statement = ( @@ -884,7 +994,11 @@ def update_product_recipe( if not inv_item or inv_item.tenant_id != current_user.tenant_id: raise HTTPException( status_code=404, - detail=f"Inventory item {item_data.inventory_item_id} not found" + detail=get_message( + "inventory_item_id_not_found", + lang, + id=item_data.inventory_item_id, + ), ) recipe_item = ProductRecipe( @@ -908,12 +1022,15 @@ def get_product_cost( product_id: int, current_user: Annotated[models.User, Depends(get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ): """Calculate theoretical cost for a product based on its recipe""" # Verify product exists product = session.get(models.Product, product_id) if not product or product.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) cost_data = calculate_product_cost(session, product_id, current_user.tenant_id) diff --git a/back/app/language_service.py b/back/app/language_service.py index 26f5f773..3451d923 100644 --- a/back/app/language_service.py +++ b/back/app/language_service.py @@ -2,6 +2,8 @@ Language service for normalizing language codes and managing translations. """ +from fastapi import Query, Header + SUPPORTED_LANGUAGES = [ "en", # English "es", # Spanish @@ -50,3 +52,38 @@ def normalize_language_code(lang: str) -> str | None: def get_supported_languages() -> list[str]: """Return list of supported language codes.""" return SUPPORTED_LANGUAGES.copy() + + +def get_requested_language( + lang: str | None = Query(None, description="Language code (e.g. en, es, zh-CN)"), + accept_language: str | None = Header( + None, alias="Accept-Language", description="Accept-Language header" + ), +) -> str: + """ + Determine the requested language based on query param or Accept-Language header. + Returns normalized language code or 'en' as fallback. + """ + # 1. Check explicit lang query parameter + if lang: + normalized = normalize_language_code(lang) + if normalized: + return normalized + + # 2. Parse Accept-Language header + if accept_language: + # Simple parsing - take the first language with highest quality + # Format: "en-US,en;q=0.9,es;q=0.8" + languages = [] + for part in accept_language.split(","): + lang_part = part.strip().split(";")[0].strip() + if lang_part: + normalized = normalize_language_code(lang_part) + if normalized: + languages.append(normalized) + + if languages: + return languages[0] # Return highest priority + + # 3. Fallback to English + return "en" diff --git a/back/app/main.py b/back/app/main.py index 5c7f26b2..c98dc3b0 100644 --- a/back/app/main.py +++ b/back/app/main.py @@ -25,6 +25,7 @@ from . import inventory_models from .translation_service import TranslationService from .messages import get_message +from .language_service import get_requested_language # Configure logging logging.basicConfig( @@ -91,41 +92,7 @@ def _get_stripe_currency_code(currency_symbol: str | None) -> str | None: return None -def _get_requested_language( - lang: str | None = Query(None, description="Language code (e.g. en, es, zh-CN)"), - accept_language: str | None = Query( - None, alias="accept-language", description="Accept-Language header" - ), -) -> str: - """ - Determine the requested language based on query param or Accept-Language header. - Returns normalized language code or 'en' as fallback. - """ - from .language_service import normalize_language_code - - # 1. Check explicit lang query parameter - if lang: - normalized = normalize_language_code(lang) - if normalized: - return normalized - - # 2. Parse Accept-Language header - if accept_language: - # Simple parsing - take the first language with highest quality - # Format: "en-US,en;q=0.9,es;q=0.8" - languages = [] - for part in accept_language.split(","): - lang_part = part.strip().split(";")[0].strip() - if lang_part: - normalized = normalize_language_code(lang_part) - if normalized: - languages.append(normalized) - - if languages: - return languages[0] # Return highest priority - - # 3. Fallback to English - return "en" +# get_requested_language replaced by import from language_service app = FastAPI( @@ -351,7 +318,10 @@ def health() -> dict: @app.get("/health/db") -def health_db(session: Session = Depends(get_session)) -> dict: +def health_db( + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), +) -> dict: """Check database connection and version.""" try: check_db_connection() @@ -369,7 +339,10 @@ def health_db(session: Session = Depends(get_session)) -> dict: return {"status": "ok", "database": "connected", "schema_version": db_version} except Exception as e: - raise HTTPException(status_code=503, detail=f"Database error: {e}") + raise HTTPException( + status_code=503, + detail=get_message("database_error", lang, e=str(e)), + ) # ============ AUTH ============ @@ -381,7 +354,7 @@ def register( email: str, password: str, full_name: str | None = None, - lang: str = Depends(_get_requested_language), + lang: str = Depends(get_requested_language), session: Session = Depends(get_session), ) -> dict: existing_user = session.exec( @@ -413,7 +386,7 @@ def register( @app.post("/token") def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - lang: str = Depends(_get_requested_language), + lang: str = Depends(get_requested_language), session: Session = Depends(get_session), ) -> dict: statement = select(models.User).where(models.User.email == form_data.username) @@ -471,6 +444,7 @@ def get_translations_for_entity( entity_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Get all translations for a specific entity.""" # Validate entity type and ensure user has access @@ -478,27 +452,35 @@ def get_translations_for_entity( if entity_type not in allowed_types: raise HTTPException( status_code=400, - detail=f"Invalid entity type. Allowed: {', '.join(allowed_types)}", + detail=get_message( + "invalid_entity_type", lang, allowed=", ".join(allowed_types) + ), ) # Check tenant ownership for tenant-scoped entities if entity_type in ["tenant", "product", "tenant_product"]: # For tenant entity, entity_id should match current tenant if entity_type == "tenant" and entity_id != current_user.tenant_id: - raise HTTPException(status_code=403, detail="Access denied") + raise HTTPException( + status_code=403, detail=get_message("access_denied", lang) + ) # For products, check tenant ownership elif entity_type == "product": product = session.exec( select(models.Product).where(models.Product.id == entity_id) ).first() if not product or product.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) elif entity_type == "tenant_product": tp = session.exec( select(models.TenantProduct).where(models.TenantProduct.id == entity_id) ).first() if not tp or tp.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) # Get all translations for this entity translations = TranslationService.get_all_translations_for_entity( @@ -519,6 +501,7 @@ def update_translations_for_entity( translations: Dict[str, Dict[str, str]], # {field: {lang: text}} current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Update translations for a specific entity.""" # Validate entity type @@ -526,25 +509,33 @@ def update_translations_for_entity( if entity_type not in allowed_types: raise HTTPException( status_code=400, - detail=f"Invalid entity type. Allowed: {', '.join(allowed_types)}", + detail=get_message( + "invalid_entity_type", lang, allowed=", ".join(allowed_types) + ), ) # Check tenant ownership for tenant-scoped entities if entity_type in ["tenant", "product", "tenant_product"]: if entity_type == "tenant" and entity_id != current_user.tenant_id: - raise HTTPException(status_code=403, detail="Access denied") + raise HTTPException( + status_code=403, detail=get_message("access_denied", lang) + ) elif entity_type == "product": product = session.exec( select(models.Product).where(models.Product.id == entity_id) ).first() if not product or product.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) elif entity_type == "tenant_product": tp = session.exec( select(models.TenantProduct).where(models.TenantProduct.id == entity_id) ).first() if not tp or tp.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) # Update translations updated_fields = [] @@ -579,6 +570,7 @@ def update_translations_for_entity( def get_tenant_settings( current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Get tenant/business profile settings.""" tenant = session.exec( @@ -586,7 +578,9 @@ def get_tenant_settings( ).first() if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_not_found", lang) + ) # Get logo file size if exists logo_size = None @@ -616,6 +610,7 @@ def update_tenant_settings( tenant_update: models.TenantUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Update tenant/business profile settings.""" tenant = session.exec( @@ -623,7 +618,9 @@ def update_tenant_settings( ).first() if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_not_found", lang) + ) # Update fields if provided (convert empty strings to None) if tenant_update.name is not None: @@ -673,7 +670,7 @@ def update_tenant_settings( if len(currency_code) != 3 or not currency_code.isalpha(): raise HTTPException( status_code=400, - detail="currency_code must be a 3-letter ISO code (e.g. EUR)", + detail=get_message("invalid_currency_code", lang), ) tenant.currency_code = currency_code else: @@ -746,6 +743,7 @@ async def upload_tenant_logo( file: Annotated[UploadFile, File()], current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Tenant: """Upload a logo for the tenant/business.""" tenant = session.exec( @@ -753,13 +751,17 @@ async def upload_tenant_logo( ).first() if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_not_found", lang) + ) # Validate content type if file.content_type not in ALLOWED_IMAGE_TYPES: raise HTTPException( status_code=400, - detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}", + detail=get_message( + "invalid_file_type", lang, allowed=", ".join(ALLOWED_IMAGE_TYPES) + ), ) # Read file and check size @@ -767,7 +769,9 @@ async def upload_tenant_logo( if len(contents) > MAX_IMAGE_SIZE: raise HTTPException( status_code=400, - detail=f"File too large. Max size: {MAX_IMAGE_SIZE // (1024 * 1024)}MB", + detail=get_message( + "file_too_large", lang, max_size=MAX_IMAGE_SIZE // (1024 * 1024) + ), ) # Optimize image locally @@ -933,6 +937,7 @@ def update_product( product_update: models.ProductUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Product: product = session.exec( select(models.Product).where( @@ -942,7 +947,9 @@ def update_product( ).first() if not product: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) if product_update.name is not None: product.name = product_update.name @@ -966,6 +973,7 @@ def delete_product( product_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: product = session.exec( select(models.Product).where( @@ -975,7 +983,9 @@ def delete_product( ).first() if not product: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) session.delete(product) session.commit() @@ -988,6 +998,7 @@ async def upload_product_image( file: Annotated[UploadFile, File()], current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Product: """Upload an image for a product. Validates file type and size.""" product = session.exec( @@ -998,13 +1009,17 @@ async def upload_product_image( ).first() if not product: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException( + status_code=404, detail=get_message("product_not_found", lang) + ) # Validate content type if file.content_type not in ALLOWED_IMAGE_TYPES: raise HTTPException( status_code=400, - detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}", + detail=get_message( + "invalid_file_type", lang, allowed=", ".join(ALLOWED_IMAGE_TYPES) + ), ) # Read file and check size @@ -1012,7 +1027,9 @@ async def upload_product_image( if len(contents) > MAX_IMAGE_SIZE: raise HTTPException( status_code=400, - detail=f"File too large. Max size: {MAX_IMAGE_SIZE // (1024 * 1024)}MB", + detail=get_message( + "file_too_large", lang, max_size=MAX_IMAGE_SIZE // (1024 * 1024) + ), ) # Optimize image locally @@ -1074,13 +1091,16 @@ def get_provider( provider_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Provider: """Get a specific provider.""" provider = session.exec( select(models.Provider).where(models.Provider.id == provider_id) ).first() if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + raise HTTPException( + status_code=404, detail=get_message("provider_not_found", lang) + ) return provider @@ -1258,6 +1278,7 @@ async def get_catalog_item( catalog_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Get a specific catalog item with price comparison.""" catalog_item = session.exec( @@ -1265,7 +1286,9 @@ async def get_catalog_item( ).first() if not catalog_item: - raise HTTPException(status_code=404, detail="Catalog item not found") + raise HTTPException( + status_code=404, detail=get_message("catalog_item_not_found", lang) + ) # Get all provider products provider_products = session.exec( @@ -1369,13 +1392,16 @@ def list_provider_products( provider_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> list[models.ProviderProduct]: """List all products from a specific provider.""" provider = session.exec( select(models.Provider).where(models.Provider.id == provider_id) ).first() if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + raise HTTPException( + status_code=404, detail=get_message("provider_not_found", lang) + ) return session.exec( select(models.ProviderProduct) @@ -1456,6 +1482,7 @@ def create_tenant_product( product_data: models.TenantProductCreate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.TenantProduct: """Add a product from catalog to tenant's menu. @@ -1470,7 +1497,9 @@ def create_tenant_product( ) ).first() if not catalog_item: - raise HTTPException(status_code=404, detail="Catalog item not found") + raise HTTPException( + status_code=404, detail=get_message("catalog_item_not_found", lang) + ) # Get provider product for additional info if specified provider_product = None @@ -1484,7 +1513,7 @@ def create_tenant_product( if not provider_product: raise HTTPException( status_code=404, - detail="Provider product not found or doesn't match catalog", + detail=get_message("provider_product_not_found", lang), ) # Use catalog name if name not provided @@ -1495,7 +1524,9 @@ def create_tenant_product( if price_cents is None and provider_product: price_cents = provider_product.price_cents if price_cents is None: - raise HTTPException(status_code=400, detail="Price is required") + raise HTTPException( + status_code=400, detail=get_message("price_required", lang) + ) # Determine category and subcategory from catalog category = catalog_item.category @@ -1637,6 +1668,7 @@ def update_tenant_product( product_update: models.TenantProductUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.TenantProduct: """Update a tenant product.""" tenant_product = session.exec( @@ -1647,7 +1679,9 @@ def update_tenant_product( ).first() if not tenant_product: - raise HTTPException(status_code=404, detail="Tenant product not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_product_not_found", lang) + ) if product_update.name is not None: tenant_product.name = product_update.name @@ -1667,6 +1701,7 @@ def delete_tenant_product( tenant_product_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Delete a tenant product.""" tenant_product = session.exec( @@ -1677,7 +1712,9 @@ def delete_tenant_product( ).first() if not tenant_product: - raise HTTPException(status_code=404, detail="Tenant product not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_product_not_found", lang) + ) session.delete(tenant_product) session.commit() @@ -1732,6 +1769,7 @@ def update_floor( floor_update: models.FloorUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Floor: """Update a floor.""" floor = session.exec( @@ -1742,7 +1780,9 @@ def update_floor( ).first() if not floor: - raise HTTPException(status_code=404, detail="Floor not found") + raise HTTPException( + status_code=404, detail=get_message("floor_not_found", lang) + ) if floor_update.name is not None: floor.name = floor_update.name @@ -1760,6 +1800,7 @@ def delete_floor( floor_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Delete a floor. Tables on this floor will have floor_id set to null.""" floor = session.exec( @@ -1770,7 +1811,9 @@ def delete_floor( ).first() if not floor: - raise HTTPException(status_code=404, detail="Floor not found") + raise HTTPException( + status_code=404, detail=get_message("floor_not_found", lang) + ) session.delete(floor) session.commit() @@ -1856,6 +1899,7 @@ def update_table( table_update: models.TableUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> models.Table: """Update table properties including canvas layout.""" table = session.exec( @@ -1866,7 +1910,9 @@ def update_table( ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) # Update all provided fields if table_update.name is not None: @@ -1899,6 +1945,7 @@ def delete_table( table_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: table = session.exec( select(models.Table).where( @@ -1908,7 +1955,9 @@ def delete_table( ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) session.delete(table) session.commit() @@ -1920,19 +1969,20 @@ def delete_table( @app.get("/internal/validate-table/{table_token}") def validate_table_token( table_token: str, - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Internal endpoint for ws-bridge to validate table tokens.""" - table = session.exec(select(models.Table).where(models.Table.token == table_token)).first() - + table = session.exec( + select(models.Table).where(models.Table.token == table_token) + ).first() + if not table: - raise HTTPException(status_code=404, detail="Table not found") - - return { - "table_id": table.id, - "tenant_id": table.tenant_id, - "valid": True - } + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) + + return {"table_id": table.id, "tenant_id": table.tenant_id, "valid": True} # ============ PUBLIC MENU ============ @@ -1941,7 +1991,7 @@ def validate_table_token( @app.get("/menu/{table_token}") def get_menu( table_token: str, - lang: str = Depends(_get_requested_language), + lang: str = Depends(get_requested_language), session: Session = Depends(get_session), ) -> dict: """Public endpoint - get menu for a table by its token.""" @@ -2347,8 +2397,11 @@ def __init__(self, id, tenant_id, name, token): @app.get("/menu/{table_token}/order") def get_current_order( table_token: str, - session_id: str | None = Query(None, description="Session identifier for order isolation"), - session: Session = Depends(get_session) + session_id: str | None = Query( + None, description="Session identifier for order isolation" + ), + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Public endpoint - get current active order for a table (if any).""" table = session.exec( @@ -2356,7 +2409,9 @@ def get_current_order( ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) # If session_id provided, look for order with matching session_id if session_id: @@ -2433,6 +2488,7 @@ def create_order( table_token: str, order_data: models.OrderCreate, session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Public endpoint - create or add to order for a table.""" table = session.exec( @@ -2440,10 +2496,15 @@ def create_order( ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) if not order_data.items: - raise HTTPException(status_code=400, detail="Order must have at least one item") + raise HTTPException( + status_code=400, + detail=get_message("order_must_have_at_least_one_item", lang), + ) # DEBUG: Log all orders for this table all_orders = session.exec( @@ -2550,11 +2611,16 @@ def create_order( tenant_product = session.exec( select(models.TenantProduct).where( models.TenantProduct.id == item.product_id, - models.TenantProduct.tenant_id == table.tenant_id + models.TenantProduct.tenant_id == table.tenant_id, ) ).first() if not tenant_product: - raise HTTPException(status_code=400, detail=f"TenantProduct {item.product_id} not found") + raise HTTPException( + status_code=400, + detail=get_message( + "tenant_product_id_not_found", lang, id=item.product_id + ), + ) product_name = tenant_product.name price_cents = tenant_product.price_cents elif item.source == "product": @@ -2566,7 +2632,12 @@ def create_order( ) ).first() if not product: - raise HTTPException(status_code=400, detail=f"Product {item.product_id} not found") + raise HTTPException( + status_code=400, + detail=get_message( + "product_id_not_found", lang, id=item.product_id + ), + ) product_name = product.name price_cents = product.price_cents else: @@ -2575,10 +2646,10 @@ def create_order( tenant_product = session.exec( select(models.TenantProduct).where( models.TenantProduct.id == item.product_id, - models.TenantProduct.tenant_id == table.tenant_id + models.TenantProduct.tenant_id == table.tenant_id, ) ).first() - + if tenant_product: product_name = tenant_product.name price_cents = tenant_product.price_cents @@ -2587,13 +2658,18 @@ def create_order( product = session.exec( select(models.Product).where( models.Product.id == item.product_id, - models.Product.tenant_id == table.tenant_id + models.Product.tenant_id == table.tenant_id, ) ).first() - + if not product: - raise HTTPException(status_code=400, detail=f"Product {item.product_id} not found") - + raise HTTPException( + status_code=400, + detail=get_message( + "product_id_not_found", lang, id=item.product_id + ), + ) + product_name = product.name price_cents = product.price_cents @@ -2793,6 +2869,7 @@ def update_order_status( status_update: models.OrderStatusUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: order = session.exec( select(models.Order).where( @@ -2802,8 +2879,10 @@ def update_order_status( ).first() if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + # Update order status order.status = status_update.status @@ -2854,24 +2933,29 @@ def mark_order_paid( order_id: int, payment_data: models.OrderMarkPaid, current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Mark order as paid manually (for cash/terminal payments).""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + # Validation: Order must be completed (all items delivered) before marking as paid if order.status != models.OrderStatus.completed: raise HTTPException( - status_code=400, - detail=f"Order must be completed before marking as paid. Current status: {order.status.value}" + status_code=400, + detail=get_message( + "order_must_be_completed", lang, status=order.status.value + ), ) # Mark as paid @@ -2906,28 +2990,32 @@ def update_order_item_status( item_id: int, status_update: models.OrderItemStatusUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Update individual order item status (restaurant staff).""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) # Update item status old_status = item.status @@ -2976,34 +3064,38 @@ def reset_item_status( order_id: int, item_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Reset item status from preparing to pending (restaurant staff only).""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Can only reset from preparing to pending if item.status != models.OrderItemStatus.preparing: raise HTTPException( status_code=400, - detail=f"Cannot reset status from {item.status.value}. Only 'preparing' items can be reset to 'pending'." + detail=get_message("cannot_reset_status", lang, status=item.status.value), ) # Reset status @@ -3050,38 +3142,43 @@ def cancel_order_item_staff( item_id: int, cancel_data: models.OrderItemCancel, current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Cancel order item (restaurant staff) - requires reason if item is ready.""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Cannot cancel delivered items if item.status == models.OrderItemStatus.delivered: - raise HTTPException(status_code=400, detail="Cannot cancel delivered items") - + raise HTTPException( + status_code=400, detail=get_message("cannot_cancel_delivered_items", lang) + ) + # Validation: If item is ready, reason is required (for tax authorities) if item.status == models.OrderItemStatus.ready and not cancel_data.reason: raise HTTPException( - status_code=400, - detail="Reason is required when cancelling ready items (required for tax reporting)" + status_code=400, detail=get_message("reason_required_cancelling", lang) ) # Cancel item (soft delete) @@ -3131,32 +3228,38 @@ def update_order_item_staff( item_id: int, item_update: models.OrderItemStaffUpdate, current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Update order item (restaurant staff) - can modify any item except delivered.""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Cannot modify delivered items if item.status == models.OrderItemStatus.delivered: - raise HTTPException(status_code=400, detail="Cannot modify delivered items") + raise HTTPException( + status_code=400, detail=get_message("cannot_modify_delivered_items", lang) + ) # Update item if item_update.quantity is not None: @@ -3215,40 +3318,47 @@ def remove_order_item_staff( order_id: int, item_id: int, current_user: Annotated[models.User, Depends(security.get_current_user)], - reason: str | None = Query(None, description="Required reason when removing ready items"), - session: Session = Depends(get_session) + reason: str | None = Query( + None, description="Required reason when removing ready items" + ), + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Remove order item (restaurant staff) - requires reason if item is ready.""" order = session.exec( select(models.Order).where( models.Order.id == order_id, - models.Order.tenant_id == current_user.tenant_id + models.Order.tenant_id == current_user.tenant_id, ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Cannot remove delivered items if item.status == models.OrderItemStatus.delivered: - raise HTTPException(status_code=400, detail="Cannot remove delivered items") - + raise HTTPException( + status_code=400, detail=get_message("cannot_remove_delivered_items", lang) + ) + # Validation: If item is ready, reason is required if item.status == models.OrderItemStatus.ready: if not reason: raise HTTPException( - status_code=400, - detail="Reason is required when removing ready items (required for tax reporting)" + status_code=400, detail=get_message("reason_required_removing", lang) ) # Soft delete @@ -3299,50 +3409,67 @@ def remove_order_item( table_token: str, order_id: int, item_id: int, - session_id: str | None = Query(None, description="Session identifier for order validation"), + session_id: str | None = Query( + None, description="Session identifier for order validation" + ), reason: str | None = Query(None, description="Optional reason for removal"), - session: Session = Depends(get_session) + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Remove item from order (soft delete - customer).""" - table = session.exec(select(models.Table).where(models.Table.token == table_token)).first() + table = session.exec( + select(models.Table).where(models.Table.token == table_token) + ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") - + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) + order = session.exec( select(models.Order).where( - models.Order.id == order_id, - models.Order.table_id == table.id + models.Order.id == order_id, models.Order.table_id == table.id ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + # Security: Validate that order belongs to this session # If order has a session_id, request must provide matching session_id if order.session_id and order.session_id != session_id: - raise HTTPException(status_code=403, detail="Order does not belong to this session") - + raise HTTPException( + status_code=403, detail=get_message("order_session_mismatch", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Cannot remove items that are already being prepared or delivered - if item.status in [models.OrderItemStatus.delivered, models.OrderItemStatus.preparing, models.OrderItemStatus.ready]: + if item.status in [ + models.OrderItemStatus.delivered, + models.OrderItemStatus.preparing, + models.OrderItemStatus.ready, + ]: status_label = { models.OrderItemStatus.delivered: "delivered", models.OrderItemStatus.preparing: "being prepared", - models.OrderItemStatus.ready: "ready" + models.OrderItemStatus.ready: "ready", }.get(item.status, "in progress") raise HTTPException( - status_code=400, - detail=f"Cannot remove items that are {status_label}. Only pending items can be removed." + status_code=400, + detail=get_message( + "cannot_remove_items_status", lang, status=status_label + ), ) # Soft delete: Mark as removed (NEVER actually delete) @@ -3389,49 +3516,66 @@ def update_order_item_quantity( order_id: int, item_id: int, item_update: models.OrderItemUpdate, - session_id: str | None = Query(None, description="Session identifier for order validation"), - session: Session = Depends(get_session) + session_id: str | None = Query( + None, description="Session identifier for order validation" + ), + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Update order item quantity (customer).""" - table = session.exec(select(models.Table).where(models.Table.token == table_token)).first() + table = session.exec( + select(models.Table).where(models.Table.token == table_token) + ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") - + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) + order = session.exec( select(models.Order).where( - models.Order.id == order_id, - models.Order.table_id == table.id + models.Order.id == order_id, models.Order.table_id == table.id ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + # Security: Validate that order belongs to this session # If order has a session_id, request must provide matching session_id if order.session_id and order.session_id != session_id: - raise HTTPException(status_code=403, detail="Order does not belong to this session") - + raise HTTPException( + status_code=403, detail=get_message("order_session_mismatch", lang) + ) + item = session.exec( select(models.OrderItem).where( - models.OrderItem.id == item_id, - models.OrderItem.order_id == order_id + models.OrderItem.id == item_id, models.OrderItem.order_id == order_id ) ).first() - + if not item: - raise HTTPException(status_code=404, detail="Order item not found") - + raise HTTPException( + status_code=404, detail=get_message("order_item_not_found", lang) + ) + # Validation: Cannot modify items that are already being prepared or delivered - if item.status in [models.OrderItemStatus.delivered, models.OrderItemStatus.preparing, models.OrderItemStatus.ready]: + if item.status in [ + models.OrderItemStatus.delivered, + models.OrderItemStatus.preparing, + models.OrderItemStatus.ready, + ]: status_label = { models.OrderItemStatus.delivered: "delivered", models.OrderItemStatus.preparing: "being prepared", - models.OrderItemStatus.ready: "ready" + models.OrderItemStatus.ready: "ready", }.get(item.status, "in progress") raise HTTPException( - status_code=400, - detail=f"Cannot modify items that are {status_label}. Only pending items can be modified." + status_code=400, + detail=get_message( + "cannot_modify_items_status", lang, status=status_label + ), ) # If quantity is 0, remove item (soft delete) @@ -3479,44 +3623,71 @@ def update_order_item_quantity( def cancel_order( table_token: str, order_id: int, - session_id: str | None = Query(None, description="Session identifier for order validation"), - session: Session = Depends(get_session) + session_id: str | None = Query( + None, description="Session identifier for order validation" + ), + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Cancel entire order (soft delete - customer).""" - table = session.exec(select(models.Table).where(models.Table.token == table_token)).first() + table = session.exec( + select(models.Table).where(models.Table.token == table_token) + ).first() if not table: - raise HTTPException(status_code=404, detail="Table not found") - + raise HTTPException( + status_code=404, detail=get_message("table_not_found", lang) + ) + order = session.exec( select(models.Order).where( - models.Order.id == order_id, - models.Order.table_id == table.id + models.Order.id == order_id, models.Order.table_id == table.id ) ).first() - + if not order: - raise HTTPException(status_code=404, detail="Order not found") - + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) + # Security: Validate that order belongs to this session # If order has a session_id, request must provide matching session_id if order.session_id and order.session_id != session_id: - raise HTTPException(status_code=403, detail="Order does not belong to this session") - + raise HTTPException( + status_code=403, detail=get_message("order_session_mismatch", lang) + ) + # Validation: Cannot cancel if any items are being prepared, ready, or delivered - items = session.exec(select(models.OrderItem).where(models.OrderItem.order_id == order_id)).all() + items = session.exec( + select(models.OrderItem).where(models.OrderItem.order_id == order_id) + ).all() active_items = [item for item in items if not item.removed_by_customer] in_progress_items = [ - item for item in active_items - if item.status in [models.OrderItemStatus.preparing, models.OrderItemStatus.ready, models.OrderItemStatus.delivered] + item + for item in active_items + if item.status + in [ + models.OrderItemStatus.preparing, + models.OrderItemStatus.ready, + models.OrderItemStatus.delivered, + ] ] if in_progress_items: statuses = [item.status.value for item in in_progress_items] if models.OrderItemStatus.delivered.value in statuses: - raise HTTPException(status_code=400, detail="Cannot cancel order with delivered items") + raise HTTPException( + status_code=400, + detail=get_message("cannot_cancel_order_delivered_items", lang), + ) elif models.OrderItemStatus.ready.value in statuses: - raise HTTPException(status_code=400, detail="Cannot cancel order with items that are ready. Only pending items can be cancelled.") + raise HTTPException( + status_code=400, + detail=get_message("cannot_cancel_order_ready_items", lang), + ) else: - raise HTTPException(status_code=400, detail="Cannot cancel order with items that are being prepared. Only pending items can be cancelled.") + raise HTTPException( + status_code=400, + detail=get_message("cannot_cancel_order_preparing_items", lang), + ) # Soft delete: Mark order and all items as cancelled order.status = models.OrderStatus.cancelled @@ -3552,7 +3723,10 @@ def cancel_order( @app.post("/orders/{order_id}/create-payment-intent") def create_payment_intent( - order_id: int, table_token: str, session: Session = Depends(get_session) + order_id: int, + table_token: str, + session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Create a Stripe PaymentIntent for an order.""" # Verify table token matches the order @@ -3561,7 +3735,9 @@ def create_payment_intent( ).first() if not table: - raise HTTPException(status_code=404, detail="Invalid table") + raise HTTPException( + status_code=404, detail=get_message("invalid_table", lang) + ) order = session.exec( select(models.Order).where( @@ -3570,7 +3746,9 @@ def create_payment_intent( ).first() if not order: - raise HTTPException(status_code=404, detail="Order not found") + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) # Calculate total from order items items = session.exec( @@ -3580,7 +3758,9 @@ def create_payment_intent( total_cents = sum(item.price_cents * item.quantity for item in items) if total_cents <= 0: - raise HTTPException(status_code=400, detail="Order has no items") + raise HTTPException( + status_code=400, detail=get_message("order_has_no_items", lang) + ) # Get tenant for description, currency, and Stripe keys tenant = session.exec( @@ -3588,13 +3768,16 @@ def create_payment_intent( ).first() if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_not_found", lang) + ) # Use tenant-specific Stripe keys, fallback to global config stripe_secret_key = tenant.stripe_secret_key or settings.stripe_secret_key if not stripe_secret_key: raise HTTPException( - status_code=400, detail="Stripe not configured for this tenant" + status_code=400, + detail=get_message("stripe_not_configured", lang), ) # Resolve Stripe currency: @@ -3639,6 +3822,7 @@ def confirm_payment( table_token: str, payment_intent_id: str, session: Session = Depends(get_session), + lang: str = Depends(get_requested_language), ) -> dict: """Mark order as paid after successful Stripe payment.""" table = session.exec( @@ -3646,7 +3830,9 @@ def confirm_payment( ).first() if not table: - raise HTTPException(status_code=404, detail="Invalid table") + raise HTTPException( + status_code=404, detail=get_message("invalid_table", lang) + ) order = session.exec( select(models.Order).where( @@ -3655,20 +3841,25 @@ def confirm_payment( ).first() if not order: - raise HTTPException(status_code=404, detail="Order not found") + raise HTTPException( + status_code=404, detail=get_message("order_not_found", lang) + ) # Get tenant for Stripe keys tenant = session.exec( select(models.Tenant).where(models.Tenant.id == order.tenant_id) ).first() if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") + raise HTTPException( + status_code=404, detail=get_message("tenant_not_found", lang) + ) # Use tenant-specific Stripe keys, fallback to global config stripe_secret_key = tenant.stripe_secret_key or settings.stripe_secret_key if not stripe_secret_key: raise HTTPException( - status_code=400, detail="Stripe not configured for this tenant" + status_code=400, + detail=get_message("stripe_not_configured", lang), ) # Verify payment with Stripe @@ -3677,7 +3868,9 @@ def confirm_payment( payment_intent_id, api_key=stripe_secret_key ) if intent.status != "succeeded": - raise HTTPException(status_code=400, detail="Payment not completed") + raise HTTPException( + status_code=400, detail=get_message("payment_not_completed", lang) + ) # Validation: Verify intent matches order # 1. Check order ID in metadata @@ -3685,7 +3878,7 @@ def confirm_payment( if not intent_order_id or str(intent_order_id) != str(order.id): raise HTTPException( status_code=400, - detail="Payment mismatch: Payment does not belong to this order", + detail=get_message("payment_mismatch_order", lang), ) # 2. Check amount @@ -3697,7 +3890,12 @@ def confirm_payment( if intent.amount != total_cents: raise HTTPException( status_code=400, - detail=f"Payment mismatch: Amount {intent.amount} does not match order total {total_cents}", + detail=get_message( + "payment_mismatch_amount", + lang, + amount=intent.amount, + total=total_cents, + ), ) # Mark order as paid diff --git a/back/app/messages.py b/back/app/messages.py index 89e6d2e8..a1f4c411 100644 --- a/back/app/messages.py +++ b/back/app/messages.py @@ -27,6 +27,35 @@ "stripe_not_configured": "Stripe not configured for this tenant", "payment_not_completed": "Payment not completed", "order_must_have_at_least_one_item": "Order must have at least one item", + "tenant_product_id_not_found": "TenantProduct {id} not found", + "product_id_not_found": "Product {id} not found", + "order_item_not_found": "Order item not found", + "cannot_cancel_delivered_items": "Cannot cancel delivered items", + "cannot_modify_delivered_items": "Cannot modify delivered items", + "cannot_remove_delivered_items": "Cannot remove delivered items", + "order_session_mismatch": "Order does not belong to this session", + "cannot_cancel_order_delivered_items": "Cannot cancel order with delivered items", + "cannot_cancel_order_ready_items": "Cannot cancel order with items that are ready. Only pending items can be cancelled.", + "cannot_cancel_order_preparing_items": "Cannot cancel order with items that are being prepared. Only pending items can be cancelled.", + "reason_required_cancelling": "Reason is required when cancelling ready items (required for tax reporting)", + "reason_required_removing": "Reason is required when removing ready items (required for tax reporting)", + "cannot_reset_status": "Cannot reset status from {status}. Only 'preparing' items can be reset to 'pending'.", + "payment_mismatch_order": "Payment mismatch: Payment does not belong to this order", + "payment_mismatch_amount": "Payment mismatch: Amount {amount} does not match order total {total}", + "cannot_modify_items_status": "Cannot modify items that are {status}. Only pending items can be modified.", + "cannot_remove_items_status": "Cannot remove items that are {status}. Only pending items can be removed.", + "order_must_be_completed": "Order must be completed before marking as paid. Current status: {status}", + "sku_exists": "SKU '{sku}' already exists", + "inventory_item_not_found": "Inventory item not found", + "invalid_adjustment_type": "Invalid adjustment type. Must be one of: {allowed}", + "supplier_not_found": "Supplier not found", + "purchase_order_not_found": "Purchase order not found", + "inventory_item_id_not_found": "Inventory item {id} not found", + "po_update_draft_only": "Can only update purchase orders in draft status", + "po_invalid_transition": "Cannot transition from {from_status} to {to_status}", + "po_receive_invalid_status": "Cannot receive goods for order in {status} status", + "po_cancel_received": "Cannot cancel a purchase order that has received goods", + "pdf_generation_failed": "PDF generation failed: {error}" }, "es": { "database_error": "Error de base de datos", @@ -52,6 +81,35 @@ "stripe_not_configured": "Stripe no configurado para este inquilino", "payment_not_completed": "Pago no completado", "order_must_have_at_least_one_item": "El pedido debe tener al menos un artículo", + "tenant_product_id_not_found": "TenantProduct {id} no encontrado", + "product_id_not_found": "Product {id} no encontrado", + "order_item_not_found": "Artículo del pedido no encontrado", + "cannot_cancel_delivered_items": "No se pueden cancelar artículos entregados", + "cannot_modify_delivered_items": "No se pueden modificar artículos entregados", + "cannot_remove_delivered_items": "No se pueden eliminar artículos entregados", + "order_session_mismatch": "El pedido no pertenece a esta sesión", + "cannot_cancel_order_delivered_items": "No se puede cancelar un pedido con artículos entregados", + "cannot_cancel_order_ready_items": "No se puede cancelar el pedido con artículos listos. Solo se pueden cancelar artículos pendientes.", + "cannot_cancel_order_preparing_items": "No se puede cancelar el pedido con artículos en preparación. Solo se pueden cancelar artículos pendientes.", + "reason_required_cancelling": "Se requiere una razón al cancelar artículos listos (requerido para informes fiscales)", + "reason_required_removing": "Se requiere una razón al eliminar artículos listos (requerido para informes fiscales)", + "cannot_reset_status": "No se puede restablecer el estado desde {status}. Solo los artículos 'preparando' se pueden restablecer a 'pendientes'.", + "payment_mismatch_order": "Error de pago: El pago no pertenece a este pedido", + "payment_mismatch_amount": "Error de pago: La cantidad {amount} no coincide con el total del pedido {total}", + "cannot_modify_items_status": "No se pueden modificar artículos que están {status}. Solo los artículos pendientes se pueden modificar.", + "cannot_remove_items_status": "No se pueden eliminar artículos que están {status}. Solo los artículos pendientes se pueden eliminar.", + "order_must_be_completed": "El pedido debe completarse antes de marcarse como pagado. Estado actual: {status}", + "sku_exists": "SKU '{sku}' ya existe", + "inventory_item_not_found": "Artículo de inventario no encontrado", + "invalid_adjustment_type": "Tipo de ajuste inválido. Debe ser uno de: {allowed}", + "supplier_not_found": "Proveedor no encontrado", + "purchase_order_not_found": "Pedido de compra no encontrado", + "inventory_item_id_not_found": "Artículo de inventario {id} no encontrado", + "po_update_draft_only": "Solo se pueden actualizar pedidos de compra en estado borrador", + "po_invalid_transition": "No se puede cambiar de {from_status} a {to_status}", + "po_receive_invalid_status": "No se pueden recibir bienes para un pedido en estado {status}", + "po_cancel_received": "No se puede cancelar un pedido de compra que ha recibido bienes", + "pdf_generation_failed": "Error al generar PDF: {error}" }, "ca": { "database_error": "Error de base de dades", @@ -77,6 +135,35 @@ "stripe_not_configured": "Stripe no configurat per aquest llogater", "payment_not_completed": "Pagament no completat", "order_must_have_at_least_one_item": "La comanda ha de tenir almenys un article", + "tenant_product_id_not_found": "TenantProduct {id} no trobat", + "product_id_not_found": "Product {id} no trobat", + "order_item_not_found": "Article de la comanda no trobat", + "cannot_cancel_delivered_items": "No es poden cancel·lar articles lliurats", + "cannot_modify_delivered_items": "No es poden modificar articles lliurats", + "cannot_remove_delivered_items": "No es poden eliminar articles lliurats", + "order_session_mismatch": "La comanda no pertany a aquesta sessió", + "cannot_cancel_order_delivered_items": "No es pot cancel·lar una comanda amb articles lliurats", + "cannot_cancel_order_ready_items": "No es pot cancel·lar la comanda amb articles llestos. Només es poden cancel·lar articles pendents.", + "cannot_cancel_order_preparing_items": "No es pot cancel·lar la comanda amb articles en preparació. Només es poden cancel·lar articles pendents.", + "reason_required_cancelling": "Es requereix una raó en cancel·lar articles llestos (requerits per a informes fiscals)", + "reason_required_removing": "Es requereix una raó en eliminar articles llestos (requerits per a informes fiscals)", + "cannot_reset_status": "No es pot restablir l'estat des de {status}. Només els articles 'preparant' es poden restablir a 'pendents'.", + "payment_mismatch_order": "Error de pagament: El pagament no pertany a aquesta comanda", + "payment_mismatch_amount": "Error de pagament: La quantitat {amount} no coincideix amb el total de la comanda {total}", + "cannot_modify_items_status": "No es poden modificar articles que estan {status}. Només els articles pendents es poden modificar.", + "cannot_remove_items_status": "No es poden eliminar articles que estan {status}. Només els articles pendents es poden eliminar.", + "order_must_be_completed": "La comanda s'ha de completar abans de marcar-la com a pagada. Estat actual: {status}", + "sku_exists": "SKU '{sku}' ja existeix", + "inventory_item_not_found": "Article d'inventari no trobat", + "invalid_adjustment_type": "Tipus d'ajust invàlid. Ha de ser un de: {allowed}", + "supplier_not_found": "Proveïdor no trobat", + "purchase_order_not_found": "Comanda de compra no trobada", + "inventory_item_id_not_found": "Article d'inventari {id} no trobat", + "po_update_draft_only": "Només es poden actualitzar comandes de compra en estat esborrany", + "po_invalid_transition": "No es pot canviar de {from_status} a {to_status}", + "po_receive_invalid_status": "No es poden rebre béns per a una comanda en estat {status}", + "po_cancel_received": "No es pot cancel·lar una comanda de compra que ha rebut béns", + "pdf_generation_failed": "Error en generar PDF: {error}" }, "de": { "database_error": "Datenbankfehler", @@ -102,6 +189,35 @@ "stripe_not_configured": "Stripe nicht für diesen Mandanten konfiguriert", "payment_not_completed": "Zahlung nicht abgeschlossen", "order_must_have_at_least_one_item": "Bestellung muss mindestens einen Artikel haben", + "tenant_product_id_not_found": "TenantProduct {id} nicht gefunden", + "product_id_not_found": "Product {id} nicht gefunden", + "order_item_not_found": "Bestellposition nicht gefunden", + "cannot_cancel_delivered_items": "Gelieferte Artikel können nicht storniert werden", + "cannot_modify_delivered_items": "Gelieferte Artikel können nicht geändert werden", + "cannot_remove_delivered_items": "Gelieferte Artikel können nicht entfernt werden", + "order_session_mismatch": "Bestellung gehört nicht zu dieser Sitzung", + "cannot_cancel_order_delivered_items": "Bestellung mit gelieferten Artikeln kann nicht storniert werden", + "cannot_cancel_order_ready_items": "Bestellung mit fertigen Artikeln kann nicht storniert werden. Nur ausstehende Artikel können storniert werden.", + "cannot_cancel_order_preparing_items": "Bestellung mit Artikeln in Zubereitung kann nicht storniert werden. Nur ausstehende Artikel können storniert werden.", + "reason_required_cancelling": "Grund ist erforderlich, wenn fertige Artikel storniert werden (für Steuerberichte erforderlich)", + "reason_required_removing": "Grund ist erforderlich, wenn fertige Artikel entfernt werden (für Steuerberichte erforderlich)", + "cannot_reset_status": "Status von {status} kann nicht zurückgesetzt werden. Nur Artikel in 'Zubereitung' können auf 'Ausstehend' zurückgesetzt werden.", + "payment_mismatch_order": "Zahlungsfehler: Zahlung gehört nicht zu dieser Bestellung", + "payment_mismatch_amount": "Zahlungsfehler: Betrag {amount} stimmt nicht mit Bestellsumme {total} überein", + "cannot_modify_items_status": "Artikel, die {status} sind, können nicht geändert werden. Nur ausstehende Artikel können geändert werden.", + "cannot_remove_items_status": "Artikel, die {status} sind, können nicht entfernt werden. Nur ausstehende Artikel können entfernt werden.", + "order_must_be_completed": "Bestellung muss abgeschlossen sein, bevor sie als bezahlt markiert wird. Aktueller Status: {status}", + "sku_exists": "SKU '{sku}' existiert bereits", + "inventory_item_not_found": "Inventarartikel nicht gefunden", + "invalid_adjustment_type": "Ungültiger Anpassungstyp. Muss einer der folgenden sein: {allowed}", + "supplier_not_found": "Lieferant nicht gefunden", + "purchase_order_not_found": "Bestellung nicht gefunden", + "inventory_item_id_not_found": "Inventarartikel {id} nicht gefunden", + "po_update_draft_only": "Bestellungen können nur im Entwurfsstatus aktualisiert werden", + "po_invalid_transition": "Übergang von {from_status} zu {to_status} nicht möglich", + "po_receive_invalid_status": "Warenannahme für Bestellung im Status {status} nicht möglich", + "po_cancel_received": "Bestellung, für die bereits Waren eingegangen sind, kann nicht storniert werden", + "pdf_generation_failed": "PDF-Erstellung fehlgeschlagen: {error}" }, "zh-CN": { "database_error": "数据库错误", @@ -127,6 +243,35 @@ "stripe_not_configured": "此租户未配置Stripe", "payment_not_completed": "付款未完成", "order_must_have_at_least_one_item": "订单必须至少有一个商品", + "tenant_product_id_not_found": "TenantProduct {id} 未找到", + "product_id_not_found": "Product {id} 未找到", + "order_item_not_found": "未找到订单商品", + "cannot_cancel_delivered_items": "无法取消已送达的商品", + "cannot_modify_delivered_items": "无法修改已送达的商品", + "cannot_remove_delivered_items": "无法移除已送达的商品", + "order_session_mismatch": "订单不属于此会话", + "cannot_cancel_order_delivered_items": "无法取消包含已送达商品的订单", + "cannot_cancel_order_ready_items": "无法取消包含已准备就好商品的订单。只能取消待处理商品。", + "cannot_cancel_order_preparing_items": "无法取消包含正在准备商品的订单。只能取消待处理商品。", + "reason_required_cancelling": "取消已准备好的商品时需要原因(税务报告需要)", + "reason_required_removing": "移除已准备好的商品时需要原因(税务报告需要)", + "cannot_reset_status": "无法重置状态 {status}。只有“准备中”的商品可以重置为“待处理”。", + "payment_mismatch_order": "付款不匹配:付款不属于此订单", + "payment_mismatch_amount": "付款不匹配:金额 {amount} 与订单总额 {total} 不匹配", + "cannot_modify_items_status": "无法修改状态为 {status} 的商品。只能修改待处理商品。", + "cannot_remove_items_status": "无法移除状态为 {status} 的商品。只能移除待处理商品。", + "order_must_be_completed": "订单必须在标记为已付款之前完成。当前状态:{status}", + "sku_exists": "SKU '{sku}' 已存在", + "inventory_item_not_found": "未找到库存物品", + "invalid_adjustment_type": "无效的调整类型。必须是以下之一:{allowed}", + "supplier_not_found": "未找到供应商", + "purchase_order_not_found": "未找到采购订单", + "inventory_item_id_not_found": "未找到库存物品 {id}", + "po_update_draft_only": "只能更新草稿状态的采购订单", + "po_invalid_transition": "无法从 {from_status} 转换为 {to_status}", + "po_receive_invalid_status": "无法接收处于 {status} 状态的订单的货物", + "po_cancel_received": "无法取消已收货的采购订单", + "pdf_generation_failed": "PDF生成失败:{error}" }, "hi": { "database_error": "डेटाबेस त्रुटि", @@ -152,7 +297,36 @@ "stripe_not_configured": "इस किरायेदार के लिए स्ट्राइप कॉन्फ़िगर नहीं किया गया है", "payment_not_completed": "भुगतान पूरा नहीं हुआ", "order_must_have_at_least_one_item": "आदेश में कम से कम एक आइटम होना चाहिए", - }, + "tenant_product_id_not_found": "TenantProduct {id} नहीं मिला", + "product_id_not_found": "Product {id} नहीं मिला", + "order_item_not_found": "आदेश आइटम नहीं मिला", + "cannot_cancel_delivered_items": "डिलीवर किए गए आइटम रद्द नहीं किए जा सकते", + "cannot_modify_delivered_items": "डिलीवर किए गए आइटम संशोधित नहीं किए जा सकते", + "cannot_remove_delivered_items": "डिलीवर किए गए आइटम हटाए नहीं जा सकते", + "order_session_mismatch": "आदेश इस सत्र से संबंधित नहीं है", + "cannot_cancel_order_delivered_items": "डिलीवर किए गए आइटम वाले ऑर्डर को रद्द नहीं किया जा सकता", + "cannot_cancel_order_ready_items": "तैयार आइटम वाले ऑर्डर को रद्द नहीं किया जा सकता। केवल लंबित आइटम रद्द किए जा सकते हैं।", + "cannot_cancel_order_preparing_items": "तैयार किए जा रहे आइटम वाले ऑर्डर को रद्द नहीं किया जा सकता। केवल लंबित आइटम रद्द किए जा सकते हैं।", + "reason_required_cancelling": "तैयार आइटम रद्द करते समय कारण आवश्यक है (कर रिपोर्टिंग के लिए आवश्यक)", + "reason_required_removing": "तैयार आइटम हटाते समय कारण आवश्यक है (कर रिपोर्टिंग के लिए आवश्यक)", + "cannot_reset_status": "{status} से स्थिति रीसेट नहीं की जा सकती। केवल 'तैयार हो रहे' आइटम को 'लंबित' पर रीसेट किया जा सकता है।", + "payment_mismatch_order": "भुगतान बेमेल: भुगतान इस आदेश से संबंधित नहीं है", + "payment_mismatch_amount": "भुगतान बेमेल: राशि {amount} ऑर्डर कुल {total} से मेल नहीं खाती", + "cannot_modify_items_status": "{status} वाले आइटम संशोधित नहीं किए जा सकते। केवल लंबित आइटम संशोधित किए जा सकते हैं।", + "cannot_remove_items_status": "{status} वाले आइटम हटाए नहीं जा सकते। केवल लंबित आइटम हटाए जा सकते हैं।", + "order_must_be_completed": "भुगतान के रूप में चिह्नित करने से पहले आदेश पूरा होना चाहिए। वर्तमान स्थिति: {status}", + "sku_exists": "SKU '{sku}' पहले से मौजूद है", + "inventory_item_not_found": "इन्वेंटरी आइटम नहीं मिला", + "invalid_adjustment_type": "अमान्य समायोजन प्रकार। निम्नलिखित में से एक होना चाहिए: {allowed}", + "supplier_not_found": "आपूर्तिकर्ता नहीं मिला", + "purchase_order_not_found": "खरीद आदेश नहीं मिला", + "inventory_item_id_not_found": "इन्वेंटरी आइटम {id} नहीं मिला", + "po_update_draft_only": "केवल ड्राफ्ट स्थिति में खरीद आदेश अपडेट किए जा सकते हैं", + "po_invalid_transition": "{from_status} से {to_status} में संक्रमण नहीं किया जा सकता", + "po_receive_invalid_status": "{status} स्थिति में ऑर्डर के लिए माल प्राप्त नहीं किया जा सकता", + "po_cancel_received": "उस खरीद आदेश को रद्द नहीं किया जा सकता जिसने माल प्राप्त किया है", + "pdf_generation_failed": "PDF निर्माण विफल: {error}" + } } diff --git a/front/public/i18n/ca.json b/front/public/i18n/ca.json index 0ed64f97..bf0db31f 100644 --- a/front/public/i18n/ca.json +++ b/front/public/i18n/ca.json @@ -25,7 +25,9 @@ "NAME": "Nom", "DESCRIPTION": "Descripció", "CATEGORY": "Categoria", - "SUBCATEGORY": "Subcategoria" + "SUBCATEGORY": "Subcategoria", + "ALL_CATEGORIES": "Totes les Categories", + "ALL_SUBCATEGORIES": "Tot {{category}}" }, "NAV": { "HOME": "Inici", @@ -104,6 +106,7 @@ "SUBCATEGORY_LABEL": "Subcategoria", "SELECT_CATEGORY": "Seleccionar Categoria", "SELECT_SUBCATEGORY": "Seleccionar Subcategoria", + "NONE": "Cap", "CHANGE_IMAGE": "Canviar Imatge", "CANCEL": "Cancel·lar", "UPDATE": "Actualitzar", @@ -123,10 +126,10 @@ "FAILED_TO_UPDATE": "Error en actualitzar", "FAILED_TO_CREATE": "Error en crear", "FAILED_TO_DELETE": "Error en eliminar", - "FAILED_TO_LOAD": "Error en carregar els productes", - "FAILED_TO_UPDATE_CATEGORY": "Error en actualitzar la categoria", - "FAILED_TO_UPLOAD_IMAGE": "Error en pujar la imatge", - "PRODUCT_CREATED_BUT_IMAGE_FAILED": "Producte creat però la pujada de la imatge ha fallat" + "FAILED_TO_LOAD": "Error en carregar productes", + "FAILED_TO_UPDATE_CATEGORY": "Error en actualitzar categoria", + "FAILED_TO_UPLOAD_IMAGE": "Error en pujar imatge", + "PRODUCT_CREATED_BUT_IMAGE_FAILED": "Producte creat però la pujada d'imatge ha fallat" }, "CATALOG": { "TITLE": "Catàleg de productes", @@ -143,7 +146,7 @@ "SEARCH_LABEL": "Cerca", "SEARCH_PLACEHOLDER": "Cerca productes...", "ALL_CATEGORIES": "Totes les Categories", - "ALL_SUBCATEGORIES": "Totes les Subcategories", + "ALL_SUBCATEGORIES": "Tot {{category}}", "LOADING_CATALOG": "Carregant catàleg...", "PRICE_PLACEHOLDER": "0.00", "ADD_PRODUCT": "Afegir Producte", @@ -448,15 +451,15 @@ "OTHER": "Altres" }, "UNITS": { - "PIECE": "Unitat", + "PIECE": "Peça", "GRAM": "Gram", - "KILOGRAM": "Quilogram", - "OUNCE": "Onça", + "KILOGRAM": "Kilogram", + "OUNCE": "Unça", "POUND": "Lliura", "MILLILITER": "Mil·lilitre", "LITER": "Litre", - "FLUID_OUNCE": "Onça líquida", - "CUP": "Taça", + "FLUID_OUNCE": "Unça líquida", + "CUP": "Tassa", "GALLON": "Galó" }, "COMMON": { diff --git a/front/public/i18n/de.json b/front/public/i18n/de.json index 3a001794..d416ce35 100644 --- a/front/public/i18n/de.json +++ b/front/public/i18n/de.json @@ -25,7 +25,9 @@ "NAME": "Name", "DESCRIPTION": "Beschreibung", "CATEGORY": "Kategorie", - "SUBCATEGORY": "Unterkategorie" + "SUBCATEGORY": "Unterkategorie", + "ALL_CATEGORIES": "Alle Kategorien", + "ALL_SUBCATEGORIES": "Alle {{category}}" }, "NAV": { "HOME": "Startseite", @@ -104,6 +106,7 @@ "SUBCATEGORY_LABEL": "Unterkategorie", "SELECT_CATEGORY": "Kategorie auswählen", "SELECT_SUBCATEGORY": "Unterkategorie auswählen", + "NONE": "Keine", "CHANGE_IMAGE": "Bild ändern", "CANCEL": "Abbrechen", "UPDATE": "Aktualisieren", @@ -123,8 +126,8 @@ "FAILED_TO_UPDATE": "Aktualisierung fehlgeschlagen", "FAILED_TO_CREATE": "Erstellung fehlgeschlagen", "FAILED_TO_DELETE": "Löschen fehlgeschlagen", - "FAILED_TO_LOAD": "Fehler beim Laden der Produkte", - "FAILED_TO_UPDATE_CATEGORY": "Kategorie-Update fehlgeschlagen", + "FAILED_TO_LOAD": "Produkte konnten nicht geladen werden", + "FAILED_TO_UPDATE_CATEGORY": "Kategorie konnte nicht aktualisiert werden", "FAILED_TO_UPLOAD_IMAGE": "Bild-Upload fehlgeschlagen", "PRODUCT_CREATED_BUT_IMAGE_FAILED": "Produkt erstellt, aber Bild-Upload fehlgeschlagen" }, @@ -143,7 +146,7 @@ "SEARCH_LABEL": "Suche", "SEARCH_PLACEHOLDER": "Produkte suchen...", "ALL_CATEGORIES": "Alle Kategorien", - "ALL_SUBCATEGORIES": "Alle Unterkategorien", + "ALL_SUBCATEGORIES": "Alle {{category}}", "LOADING_CATALOG": "Katalog wird geladen...", "PRICE_PLACEHOLDER": "0.00", "ADD_PRODUCT": "Produkt hinzufügen", @@ -445,7 +448,7 @@ "PACKAGING": "Verpackung", "CLEANING": "Reinigung", "EQUIPMENT": "Ausrüstung", - "OTHER": "Sonstiges" + "OTHER": "Andere" }, "UNITS": { "PIECE": "Stück", diff --git a/front/public/i18n/en.json b/front/public/i18n/en.json index dd730fc6..fb39064e 100644 --- a/front/public/i18n/en.json +++ b/front/public/i18n/en.json @@ -25,7 +25,9 @@ "NAME": "Name", "DESCRIPTION": "Description", "CATEGORY": "Category", - "SUBCATEGORY": "Subcategory" + "SUBCATEGORY": "Subcategory", + "ALL_CATEGORIES": "All Categories", + "ALL_SUBCATEGORIES": "All {{category}}" }, "NAV": { "HOME": "Home", @@ -102,6 +104,7 @@ "SUBCATEGORY_LABEL": "Subcategory", "SELECT_CATEGORY": "Select Category", "SELECT_SUBCATEGORY": "Select Subcategory", + "NONE": "None", "UPLOAD_IMAGE": "Upload Image", "CHANGE_IMAGE": "Change Image", "CANCEL": "Cancel", @@ -136,7 +139,7 @@ "SEARCH_LABEL": "Search", "SEARCH_PLACEHOLDER": "Search products...", "ALL_CATEGORIES": "All Categories", - "ALL_SUBCATEGORIES": "All Subcategories", + "ALL_SUBCATEGORIES": "All {{category}}", "LOADING_CATALOG": "Loading catalog...", "ADD_TO_MENU": "Add to Menu", "REMOVE_FROM_MENU": "Remove from Menu", diff --git a/front/public/i18n/es.json b/front/public/i18n/es.json index 824a4f87..6e4769e2 100644 --- a/front/public/i18n/es.json +++ b/front/public/i18n/es.json @@ -25,7 +25,9 @@ "NAME": "Nombre", "DESCRIPTION": "Descripción", "CATEGORY": "Categoría", - "SUBCATEGORY": "Subcategoría" + "SUBCATEGORY": "Subcategoría", + "ALL_CATEGORIES": "Todas las Categorías", + "ALL_SUBCATEGORIES": "Todo {{category}}" }, "NAV": { "HOME": "Inicio", @@ -104,6 +106,7 @@ "SUBCATEGORY_LABEL": "Subcategoría", "SELECT_CATEGORY": "Seleccionar Categoría", "SELECT_SUBCATEGORY": "Seleccionar Subcategoría", + "NONE": "Ninguno", "CHANGE_IMAGE": "Cambiar Imagen", "CANCEL": "Cancelar", "UPDATE": "Actualizar", @@ -143,7 +146,7 @@ "SEARCH_LABEL": "Buscar", "SEARCH_PLACEHOLDER": "Buscar productos...", "ALL_CATEGORIES": "Todas las Categorías", - "ALL_SUBCATEGORIES": "Todas las Subcategorías", + "ALL_SUBCATEGORIES": "Todo {{category}}", "LOADING_CATALOG": "Cargando catálogo...", "PRICE_PLACEHOLDER": "0.00", "ADD_PRODUCT": "Añadir Producto", @@ -467,7 +470,7 @@ "OTHER": "Otros" }, "UNITS": { - "PIECE": "Unidad", + "PIECE": "Pieza", "GRAM": "Gramo", "KILOGRAM": "Kilogramo", "OUNCE": "Onza", diff --git a/front/public/i18n/hi.json b/front/public/i18n/hi.json index ed7db62d..2207e50a 100644 --- a/front/public/i18n/hi.json +++ b/front/public/i18n/hi.json @@ -25,7 +25,9 @@ "NAME": "नाम", "DESCRIPTION": "विवरण", "CATEGORY": "श्रेणी", - "SUBCATEGORY": "उपश्रेणी" + "SUBCATEGORY": "उपश्रेणी", + "ALL_CATEGORIES": "सभी श्रेणियां", + "ALL_SUBCATEGORIES": "सभी {{category}}" }, "NAV": { "HOME": "होम", @@ -104,6 +106,7 @@ "SUBCATEGORY_LABEL": "उपश्रेणी", "SELECT_CATEGORY": "श्रेणी चुनें", "SELECT_SUBCATEGORY": "उपश्रेणी चुनें", + "NONE": "कोई नहीं", "CHANGE_IMAGE": "छवि बदलें", "CANCEL": "रद्द करें", "UPDATE": "अपडेट करें", @@ -123,10 +126,10 @@ "FAILED_TO_UPDATE": "अपडेट करने में विफल", "FAILED_TO_CREATE": "बनाने में विफल", "FAILED_TO_DELETE": "हटाने में विफल", - "FAILED_TO_LOAD": "उत्पाद लोड करने में विफल", + "FAILED_TO_LOAD": "उत्पादों को लोड करने में विफल", "FAILED_TO_UPDATE_CATEGORY": "श्रेणी अपडेट करने में विफल", "FAILED_TO_UPLOAD_IMAGE": "छवि अपलोड करने में विफल", - "PRODUCT_CREATED_BUT_IMAGE_FAILED": "उत्पाद बन गया लेकिन छवि अपलोड विफल रही" + "PRODUCT_CREATED_BUT_IMAGE_FAILED": "उत्पाद बनाया गया लेकिन छवि अपलोड विफल" }, "CATALOG": { "TITLE": "उत्पाद कैटलॉग", @@ -143,7 +146,7 @@ "SEARCH_LABEL": "खोजें", "SEARCH_PLACEHOLDER": "उत्पाद खोजें...", "ALL_CATEGORIES": "सभी श्रेणियां", - "ALL_SUBCATEGORIES": "सभी उपश्रेणियां", + "ALL_SUBCATEGORIES": "सभी {{category}}", "LOADING_CATALOG": "कैटलॉग लोड हो रहा है...", "PRICE_PLACEHOLDER": "0.00", "ADD_PRODUCT": "उत्पाद जोड़ें", @@ -448,14 +451,14 @@ "OTHER": "अन्य" }, "UNITS": { - "PIECE": "पीस", + "PIECE": "टुकड़ा", "GRAM": "ग्राम", "KILOGRAM": "किलोग्राम", "OUNCE": "औंस", "POUND": "पाउंड", "MILLILITER": "मिलीलीटर", "LITER": "लीटर", - "FLUID_OUNCE": "द्रव औंस", + "FLUID_OUNCE": "तरल औंस", "CUP": "कप", "GALLON": "गैलन" }, diff --git a/front/public/i18n/zh-CN.json b/front/public/i18n/zh-CN.json index e56bdbd2..517a242f 100644 --- a/front/public/i18n/zh-CN.json +++ b/front/public/i18n/zh-CN.json @@ -25,7 +25,9 @@ "NAME": "名称", "DESCRIPTION": "描述", "CATEGORY": "类别", - "SUBCATEGORY": "子类别" + "SUBCATEGORY": "子类别", + "ALL_CATEGORIES": "所有类别", + "ALL_SUBCATEGORIES": "所有 {{category}}" }, "NAV": { "HOME": "首页", @@ -102,6 +104,7 @@ "SUBCATEGORY_LABEL": "子类别", "SELECT_CATEGORY": "选择类别", "SELECT_SUBCATEGORY": "选择子类别", + "NONE": "无", "CHANGE_IMAGE": "更换图片", "CANCEL": "取消", "UPDATE": "更新", @@ -124,7 +127,7 @@ "FAILED_TO_LOAD": "加载产品失败", "FAILED_TO_UPDATE_CATEGORY": "更新类别失败", "FAILED_TO_UPLOAD_IMAGE": "上传图片失败", - "PRODUCT_CREATED_BUT_IMAGE_FAILED": "产品已创建,但图片上传失败" + "PRODUCT_CREATED_BUT_IMAGE_FAILED": "产品已创建但图片上传失败" }, "CATALOG": { "TITLE": "产品目录", @@ -141,7 +144,7 @@ "SEARCH_LABEL": "搜索", "SEARCH_PLACEHOLDER": "搜索产品...", "ALL_CATEGORIES": "所有类别", - "ALL_SUBCATEGORIES": "所有子类别", + "ALL_SUBCATEGORIES": "所有 {{category}}", "LOADING_CATALOG": "目录加载中...", "PRICE_PLACEHOLDER": "0.00", "ADD_PRODUCT": "添加产品", @@ -438,22 +441,22 @@ "NOTES": "备注" }, "CATEGORIES": { - "INGREDIENTS": "原料", - "BEVERAGES": "饮品", + "INGREDIENTS": "配料", + "BEVERAGES": "饮料", "PACKAGING": "包装", "CLEANING": "清洁", "EQUIPMENT": "设备", "OTHER": "其他" }, "UNITS": { - "PIECE": "件/个", + "PIECE": "件", "GRAM": "克", "KILOGRAM": "千克", "OUNCE": "盎司", "POUND": "磅", "MILLILITER": "毫升", "LITER": "升", - "FLUID_OUNCE": "液量盎司", + "FLUID_OUNCE": "液盎司", "CUP": "杯", "GALLON": "加仑" }, diff --git a/front/src/app/inventory/inventory-items/inventory-items.component.ts b/front/src/app/inventory/inventory-items/inventory-items.component.ts index cd387291..d0ca8236 100644 --- a/front/src/app/inventory/inventory-items/inventory-items.component.ts +++ b/front/src/app/inventory/inventory-items/inventory-items.component.ts @@ -54,7 +54,7 @@ import {