From 9701fa31b218c361743e85fb76bbb071ae333a74 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Fri, 31 Oct 2025 18:02:23 +0000 Subject: [PATCH] progress in invoices --- hesabixAPI/adapters/api/v1/accounts.py | 120 +- hesabixAPI/adapters/api/v1/boms.py | 19 +- hesabixAPI/adapters/api/v1/businesses.py | 35 + hesabixAPI/adapters/api/v1/invoices.py | 770 +++++++++++- .../adapters/api/v1/schema_models/account.py | 14 + .../api/v1/schema_models/product_bom.py | 15 + .../adapters/db/repositories/base_repo.py | 8 +- .../db/repositories/document_repository.py | 6 +- hesabixAPI/app/core/permissions.py | 5 +- hesabixAPI/app/services/account_service.py | 106 ++ hesabixAPI/app/services/bom_service.py | 109 +- hesabixAPI/app/services/invoice_service.py | 1059 +++++++++++++++++ .../docs/INVOICE_ISSUES_AND_SCENARIOS.md | 432 +++++++ .../20251021_000601_add_bom_and_warehouses.py | 185 +-- hesabixUI/hesabix_ui/lib/main.dart | 22 +- .../hesabix_ui/lib/models/bom_models.dart | 31 + .../lib/models/invoice_list_item.dart | 60 + .../lib/pages/business/accounts_page.dart | 104 ++ .../business/business_info_settings_page.dart | 334 ++++++ .../pages/business/invoices_list_page.dart | 310 +++++ .../lib/pages/business/new_invoice_page.dart | 264 ++-- .../lib/pages/business/settings_page.dart | 29 +- .../lib/services/account_service.dart | 2 +- .../hesabix_ui/lib/services/bom_service.dart | 13 + .../services/production_settings_service.dart | 29 + .../document/document_form_dialog.dart | 55 +- .../widgets/product/bom_editor_dialog.dart | 487 ++++++++ .../product/production_settings_dialog.dart | 99 ++ .../product/sections/product_bom_section.dart | 169 ++- 29 files changed, 4654 insertions(+), 237 deletions(-) create mode 100644 hesabixAPI/app/services/account_service.py create mode 100644 hesabixAPI/app/services/invoice_service.py create mode 100644 hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md create mode 100644 hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/business_info_settings_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/production_settings_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/product/production_settings_dialog.dart diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py index a7f53c8..6d64d71 100644 --- a/hesabixAPI/adapters/api/v1/accounts.py +++ b/hesabixAPI/adapters/api/v1/accounts.py @@ -1,16 +1,17 @@ from typing import List, Dict, Any, Optional -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Body from sqlalchemy.orm import Session from pydantic import BaseModel from adapters.db.session import get_db from adapters.api.v1.schemas import SuccessResponse -from adapters.api.v1.schema_models.account import AccountTreeNode -from app.core.responses import success_response +from adapters.api.v1.schema_models.account import AccountTreeNode, AccountCreateRequest, AccountUpdateRequest +from app.core.responses import success_response, ApiError from app.core.auth_dependency import get_current_user, AuthContext from app.core.permissions import require_business_access from adapters.db.models.account import Account +from app.services.account_service import create_account, update_account, delete_account, get_account router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -197,3 +198,116 @@ def search_accounts( }, request) +@router.post( + "/business/{business_id}/create", + summary="ایجاد حساب جدید برای یک کسب‌وکار", + description="ایجاد حساب اختصاصی (business-specific).", +) +@require_business_access("business_id") +def create_business_account( + request: Request, + business_id: int, + body: AccountCreateRequest = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + # اجازه نوشتن در بخش حسابداری لازم است + if not ctx.can_write_section("accounting"): + raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) + try: + created = create_account( + db, + name=body.name, + code=body.code, + account_type=body.account_type, + business_id=business_id, + parent_id=body.parent_id, + ) + return success_response(created, request, message="ACCOUNT_CREATED") + except ValueError as e: + code = str(e) + if code == "ACCOUNT_CODE_NOT_UNIQUE": + raise ApiError("ACCOUNT_CODE_NOT_UNIQUE", "Account code must be unique per business", http_status=400) + if code == "PARENT_NOT_FOUND": + raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400) + if code == "INVALID_PARENT_BUSINESS": + raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400) + raise + + +@router.put( + "/account/{account_id}", + summary="ویرایش حساب", + description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", +) +def update_account_endpoint( + request: Request, + account_id: int, + body: AccountUpdateRequest = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + data = get_account(db, account_id) + if not data: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) + acc_business_id = data.get("business_id") + # اگر عمومی است، فقط سوپرادمین + if acc_business_id is None and not ctx.is_superadmin(): + raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403) + # اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد + if acc_business_id is not None: + if not ctx.can_access_business(int(acc_business_id)): + raise ApiError("FORBIDDEN", "No access to business", http_status=403) + if not ctx.can_write_section("accounting"): + raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) + try: + updated = update_account( + db, + account_id, + name=body.name, + code=body.code, + account_type=body.account_type, + parent_id=body.parent_id, + ) + if updated is None: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) + return success_response(updated, request, message="ACCOUNT_UPDATED") + except ValueError as e: + code = str(e) + if code == "ACCOUNT_CODE_NOT_UNIQUE": + raise ApiError("ACCOUNT_CODE_NOT_UNIQUE", "Account code must be unique per business", http_status=400) + if code == "PARENT_NOT_FOUND": + raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400) + if code == "INVALID_PARENT_BUSINESS": + raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400) + raise + + +@router.delete( + "/account/{account_id}", + summary="حذف حساب", + description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", +) +def delete_account_endpoint( + request: Request, + account_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + data = get_account(db, account_id) + if not data: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) + acc_business_id = data.get("business_id") + if acc_business_id is None and not ctx.is_superadmin(): + raise ApiError("FORBIDDEN", "Only superadmin can delete public accounts", http_status=403) + if acc_business_id is not None: + if not ctx.can_access_business(int(acc_business_id)): + raise ApiError("FORBIDDEN", "No access to business", http_status=403) + if not ctx.can_write_section("accounting"): + raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) + ok = delete_account(db, account_id) + if not ok: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) + return success_response(None, request, message="ACCOUNT_DELETED") + + diff --git a/hesabixAPI/adapters/api/v1/boms.py b/hesabixAPI/adapters/api/v1/boms.py index e34dda3..3f45eda 100644 --- a/hesabixAPI/adapters/api/v1/boms.py +++ b/hesabixAPI/adapters/api/v1/boms.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Dict, Any, Optional from fastapi import APIRouter, Depends, Request, Query from sqlalchemy.orm import Session @@ -12,6 +10,7 @@ from adapters.api.v1.schema_models.product_bom import ( ProductBOMCreateRequest, ProductBOMUpdateRequest, BOMExplosionRequest, + ProductionDraftRequest, ) from app.services.bom_service import ( create_bom, @@ -20,6 +19,7 @@ from app.services.bom_service import ( update_bom, delete_bom, explode_bom, + produce_draft, ) @@ -119,3 +119,18 @@ def explode_bom_endpoint( raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) result = explode_bom(db, business_id, payload) return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.post("/business/{business_id}/produce_draft") +@require_business_access("business_id") +def produce_draft_endpoint( + request: Request, + business_id: int, + payload: ProductionDraftRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = produce_draft(db, business_id, payload) + return success_response(data=format_datetime_fields(result, request), request=request) diff --git a/hesabixAPI/adapters/api/v1/businesses.py b/hesabixAPI/adapters/api/v1/businesses.py index 8d93b81..bb6156d 100644 --- a/hesabixAPI/adapters/api/v1/businesses.py +++ b/hesabixAPI/adapters/api/v1/businesses.py @@ -131,6 +131,40 @@ def list_user_businesses( return success_response(formatted_data, request) +@router.get("/{business_id}", + summary="جزئیات کسب و کار", + description="دریافت جزئیات یک کسب و کار خاص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "جزئیات کسب و کار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "جزئیات کسب و کار دریافت شد", + "data": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "address": "تهران، خیابان ولیعصر", + "phone": "02112345678", + "created_at": "1403/01/01 00:00:00" + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) @router.post("/{business_id}/details", summary="جزئیات کسب و کار", description="دریافت جزئیات یک کسب و کار خاص", @@ -217,6 +251,7 @@ def get_business( } } ) +@require_business_management() def update_business_info( request: Request, business_id: int, diff --git a/hesabixAPI/adapters/api/v1/invoices.py b/hesabixAPI/adapters/api/v1/invoices.py index 312fdc8..442b29f 100644 --- a/hesabixAPI/adapters/api/v1/invoices.py +++ b/hesabixAPI/adapters/api/v1/invoices.py @@ -1,12 +1,30 @@ -from typing import Dict, Any -from fastapi import APIRouter, Depends, Request +from typing import Dict, Any, List, Optional +from fastapi import APIRouter, Depends, Request, Body +from fastapi.responses import Response from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +import io +import json +import datetime +import re from adapters.db.session import get_db from app.core.auth_dependency import get_current_user, AuthContext from app.core.permissions import require_business_access -from app.core.responses import success_response +from app.core.responses import success_response, format_datetime_fields from adapters.api.v1.schemas import QueryInfo +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.account import Account +from adapters.db.models.currency import Currency +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.business import Business +from app.services.invoice_service import ( + create_invoice, + update_invoice, + invoice_document_to_dict, + SUPPORTED_INVOICE_TYPES, +) router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only @@ -17,12 +35,17 @@ router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only def create_invoice_endpoint( request: Request, business_id: int, - payload: Dict[str, Any], + payload: Dict[str, Any] = Body(...), ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ) -> Dict[str, Any]: - # Stub only: no implementation yet - return success_response(data={}, request=request, message="INVOICE_CREATE_STUB") + result = create_invoice( + db=db, + business_id=business_id, + user_id=ctx.get_user_id(), + data=payload, + ) + return success_response(data=result, request=request, message="INVOICE_CREATED") @router.put("/business/{business_id}/{invoice_id}") @@ -31,12 +54,23 @@ def update_invoice_endpoint( request: Request, business_id: int, invoice_id: int, - payload: Dict[str, Any], + payload: Dict[str, Any] = Body(...), ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ) -> Dict[str, Any]: - # Stub only: no implementation yet - return success_response(data={}, request=request, message="INVOICE_UPDATE_STUB") + # Optional safety: ensure ownership + doc = db.query(Document).filter(Document.id == invoice_id).first() + if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES: + # Lazy import to avoid circular + from app.core.responses import ApiError + raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404) + result = update_invoice( + db=db, + document_id=invoice_id, + user_id=ctx.get_user_id(), + data=payload, + ) + return success_response(data=result, request=request, message="INVOICE_UPDATED") @router.get("/business/{business_id}/{invoice_id}") @@ -48,20 +82,726 @@ def get_invoice_endpoint( ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ) -> Dict[str, Any]: - # Stub only: no implementation yet - return success_response(data={"item": None}, request=request, message="INVOICE_GET_STUB") + doc = db.query(Document).filter(Document.id == invoice_id).first() + if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES: + from app.core.responses import ApiError + raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404) + result = invoice_document_to_dict(db, doc) + return success_response(data={"item": result}, request=request, message="INVOICE") @router.post("/business/{business_id}/search") @require_business_access("business_id") -def search_invoices_endpoint( +async def search_invoices_endpoint( request: Request, business_id: int, - query_info: QueryInfo, + query_info: QueryInfo = Body(...), ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ) -> Dict[str, Any]: - # Stub only: no implementation yet - return success_response(data={"items": [], "total": 0, "take": query_info.take, "skip": query_info.skip}, request=request, message="INVOICE_SEARCH_STUB") + """لیست فاکتورها با فیلتر، جست‌وجو، مرتب‌سازی و صفحه‌بندی استاندارد""" + + # Base query + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)), + ) + ) + + # Merge flat body extras similar to other list endpoints + body: Dict[str, Any] = {} + try: + body_json = await request.json() + if isinstance(body_json, dict): + body = body_json + except Exception: + body = {} + + # Simple search on code/description + search: Optional[str] = getattr(query_info, 'search', None) + if isinstance(search, str) and search.strip(): + s = f"%{search.strip()}%" + q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s))) + + # Extra filters + doc_type = body.get("document_type") + if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES: + q = q.filter(Document.document_type == doc_type) + + is_proforma = body.get("is_proforma") + if isinstance(is_proforma, bool): + q = q.filter(Document.is_proforma == is_proforma) + + currency_id = body.get("currency_id") + try: + if currency_id is not None: + q = q.filter(Document.currency_id == int(currency_id)) + except Exception: + pass + + # Fiscal year from header or body + fiscal_year_id = None + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + fiscal_year_id = int(fy_header) + except Exception: + fiscal_year_id = None + if fiscal_year_id is None: + try: + if body.get("fiscal_year_id") is not None: + fiscal_year_id = int(body.get("fiscal_year_id")) + except Exception: + fiscal_year_id = None + if fiscal_year_id is not None: + q = q.filter(Document.fiscal_year_id == fiscal_year_id) + + # Date range from filters or flat body + # 1) From QueryInfo.filters operators + try: + filters = getattr(query_info, 'filters', None) + except Exception: + filters = None + if filters and isinstance(filters, (list, tuple)): + for flt in filters: + try: + prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property') + op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator') + val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value') + if prop == 'document_date' and isinstance(val, str) and val: + from app.services.transfer_service import _parse_iso_date as _p + dt = _p(val) + col = getattr(Document, prop) + if op == ">=": + q = q.filter(col >= dt) + elif op == "<=": + q = q.filter(col <= dt) + except Exception: + pass + + # 2) From flat body keys + if isinstance(body.get("from_date"), str): + try: + from app.services.transfer_service import _parse_iso_date as _p + q = q.filter(Document.document_date >= _p(body.get("from_date"))) + except Exception: + pass + if isinstance(body.get("to_date"), str): + try: + from app.services.transfer_service import _parse_iso_date as _p + q = q.filter(Document.document_date <= _p(body.get("to_date"))) + except Exception: + pass + + # Sorting + sort_desc = bool(getattr(query_info, 'sort_desc', True)) + sort_by = getattr(query_info, 'sort_by', None) or 'document_date' + sort_col = Document.document_date + if isinstance(sort_by, str): + if sort_by == 'code' and hasattr(Document, 'code'): + sort_col = Document.code + elif sort_by == 'created_at' and hasattr(Document, 'created_at'): + sort_col = Document.created_at + elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'): + sort_col = Document.registered_at + else: + sort_col = Document.document_date + q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc()) + + # Pagination + take = int(getattr(query_info, 'take', 20) or 20) + skip = int(getattr(query_info, 'skip', 0) or 0) + + total = q.count() + items: List[Document] = q.offset(skip).limit(take).all() + + # Helpers for display fields + def _type_name(tp: str) -> str: + mapping = { + 'invoice_sales': 'فروش', + 'invoice_sales_return': 'برگشت از فروش', + 'invoice_purchase': 'خرید', + 'invoice_purchase_return': 'برگشت از خرید', + 'invoice_direct_consumption': 'مصرف مستقیم', + 'invoice_production': 'تولید', + 'invoice_waste': 'ضایعات', + } + return mapping.get(str(tp), str(tp)) + + data_items: List[Dict[str, Any]] = [] + for d in items: + item = invoice_document_to_dict(db, d) + # total_amount from extra_info.totals.net if available + total_amount = None + try: + totals = (item.get('extra_info') or {}).get('totals') or {} + if isinstance(totals, dict) and 'net' in totals: + total_amount = totals.get('net') + except Exception: + total_amount = None + # Fallback compute from product lines + if total_amount is None: + try: + net_sum = 0.0 + for pl in item.get('product_lines', []) or []: + info = pl.get('extra_info') or {} + qty = float(pl.get('quantity') or 0) + unit_price = float(info.get('unit_price') or 0) + line_discount = float(info.get('line_discount') or 0) + tax_amount = float(info.get('tax_amount') or 0) + line_total = info.get('line_total') + if line_total is None: + line_total = (qty * unit_price) - line_discount + tax_amount + net_sum += float(line_total) + total_amount = float(net_sum) + except Exception: + total_amount = None + + item['document_type_name'] = _type_name(item.get('document_type')) + if total_amount is not None: + item['total_amount'] = total_amount + data_items.append(format_datetime_fields(item, request)) + + # Build pagination info + page = (skip // take) + 1 if take > 0 else 1 + total_pages = (total + take - 1) // take if take > 0 else 1 + + return success_response( + data={ + "items": data_items, + "total": total, + "take": take, + "skip": skip, + # Optional standard pagination shape (supported by UI model) + "pagination": { + "page": page, + "per_page": take, + "total": total, + "total_pages": total_pages, + }, + # Flat shape too, for compatibility + "page": page, + "limit": take, + "total_pages": total_pages, + }, + request=request, + message="INVOICE_LIST", + ) + +@router.post( + "/business/{business_id}/export/excel", + summary="خروجی Excel لیست فاکتورها", + description="خروجی Excel لیست فاکتورها با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها", +) +@require_business_access("business_id") +async def export_invoices_excel( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + # Build base query similar to search endpoint + take_value = min(int(body.get("take", 1000)), 10000) + skip_value = int(body.get("skip", 0)) + + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)), + ) + ) + + # Search + search = body.get("search") + if isinstance(search, str) and search.strip(): + s = f"%{search.strip()}%" + q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s))) + + # Filters + doc_type = body.get("document_type") + if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES: + q = q.filter(Document.document_type == doc_type) + + is_proforma = body.get("is_proforma") + if isinstance(is_proforma, bool): + q = q.filter(Document.is_proforma == is_proforma) + + currency_id = body.get("currency_id") + try: + if currency_id is not None: + q = q.filter(Document.currency_id == int(currency_id)) + except Exception: + pass + + # Fiscal year + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + q = q.filter(Document.fiscal_year_id == int(fy_header)) + elif body.get("fiscal_year_id") is not None: + q = q.filter(Document.fiscal_year_id == int(body.get("fiscal_year_id"))) + except Exception: + pass + + # Date range + from app.services.transfer_service import _parse_iso_date as _p + if isinstance(body.get("from_date"), str): + try: + q = q.filter(Document.document_date >= _p(body.get("from_date"))) + except Exception: + pass + if isinstance(body.get("to_date"), str): + try: + q = q.filter(Document.document_date <= _p(body.get("to_date"))) + except Exception: + pass + + # Sorting + sort_desc = bool(body.get("sort_desc", True)) + sort_by = body.get("sort_by") or "document_date" + sort_col = Document.document_date + if sort_by == 'code' and hasattr(Document, 'code'): + sort_col = Document.code + elif sort_by == 'created_at' and hasattr(Document, 'created_at'): + sort_col = Document.created_at + elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'): + sort_col = Document.registered_at + q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc()) + + total = q.count() + docs: List[Document] = q.offset(skip_value).limit(take_value).all() + + # Build items like list endpoint + def _type_name(tp: str) -> str: + mapping = { + 'invoice_sales': 'فروش', + 'invoice_sales_return': 'برگشت از فروش', + 'invoice_purchase': 'خرید', + 'invoice_purchase_return': 'برگشت از خرید', + 'invoice_direct_consumption': 'مصرف مستقیم', + 'invoice_production': 'تولید', + 'invoice_waste': 'ضایعات', + } + return mapping.get(str(tp), str(tp)) + + items: List[Dict[str, Any]] = [] + for d in docs: + item = invoice_document_to_dict(db, d) + # total_amount + total_amount = None + try: + totals = (item.get('extra_info') or {}).get('totals') or {} + if isinstance(totals, dict) and 'net' in totals: + total_amount = totals.get('net') + except Exception: + total_amount = None + if total_amount is None: + try: + net_sum = 0.0 + for pl in item.get('product_lines', []) or []: + info = pl.get('extra_info') or {} + qty = float(pl.get('quantity') or 0) + unit_price = float(info.get('unit_price') or 0) + line_discount = float(info.get('line_discount') or 0) + tax_amount = float(info.get('tax_amount') or 0) + line_total = info.get('line_total') + if line_total is None: + line_total = (qty * unit_price) - line_discount + tax_amount + net_sum += float(line_total) + total_amount = float(net_sum) + except Exception: + total_amount = None + + item['document_type_name'] = _type_name(item.get('document_type')) + if total_amount is not None: + item['total_amount'] = total_amount + items.append(format_datetime_fields(item, request)) + + # Handle selected rows + selected_only = bool(body.get('selected_only', False)) + selected_indices = body.get('selected_indices') + if selected_only and selected_indices is not None: + indices = None + if isinstance(selected_indices, str): + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + elif isinstance(selected_indices, list): + indices = selected_indices + if isinstance(indices, list): + items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)] + + # Prepare columns + headers: List[str] = [] + keys: List[str] = [] + export_columns = body.get('export_columns') + if export_columns: + for col in export_columns: + key = col.get('key') + label = col.get('label', key) + if key: + keys.append(str(key)) + headers.append(str(label)) + else: + default_columns = [ + ('code', 'کد سند'), + ('document_type_name', 'نوع فاکتور'), + ('document_date', 'تاریخ سند'), + ('total_amount', 'مبلغ کل'), + ('currency_code', 'ارز'), + ('created_by_name', 'ایجادکننده'), + ('is_proforma', 'وضعیت'), + ('registered_at', 'تاریخ ثبت'), + ] + for key, label in default_columns: + if items and key in items[0]: + keys.append(key) + headers.append(label) + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Invoices" + + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + + # Header row + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border + + # Data rows + for row_idx, item in enumerate(items, 2): + for col_idx, key in enumerate(keys, 1): + value = item.get(key, "") + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + elif isinstance(value, dict): + value = str(value) + ws.cell(row=row_idx, column=col_idx, value=value).border = border + + # Auto-width + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + # Save to bytes + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + # Filename + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + + base = "invoices" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + content = buffer.getvalue() + + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(content)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.post( + "/business/{business_id}/export/pdf", + summary="خروجی PDF لیست فاکتورها", + description="خروجی PDF لیست فاکتورها با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها", +) +@require_business_access("business_id") +async def export_invoices_pdf( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + from weasyprint import HTML + from weasyprint.text.fonts import FontConfiguration + from app.core.i18n import negotiate_locale + from html import escape + + # Build same list as excel + take_value = min(int(body.get("take", 1000)), 10000) + skip_value = int(body.get("skip", 0)) + + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)), + ) + ) + + search = body.get("search") + if isinstance(search, str) and search.strip(): + s = f"%{search.strip()}%" + q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s))) + + doc_type = body.get("document_type") + if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES: + q = q.filter(Document.document_type == doc_type) + + is_proforma = body.get("is_proforma") + if isinstance(is_proforma, bool): + q = q.filter(Document.is_proforma == is_proforma) + + currency_id = body.get("currency_id") + try: + if currency_id is not None: + q = q.filter(Document.currency_id == int(currency_id)) + except Exception: + pass + + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + q = q.filter(Document.fiscal_year_id == int(fy_header)) + elif body.get("fiscal_year_id") is not None: + q = q.filter(Document.fiscal_year_id == int(body.get("fiscal_year_id"))) + except Exception: + pass + + from app.services.transfer_service import _parse_iso_date as _p + if isinstance(body.get("from_date"), str): + try: + q = q.filter(Document.document_date >= _p(body.get("from_date"))) + except Exception: + pass + if isinstance(body.get("to_date"), str): + try: + q = q.filter(Document.document_date <= _p(body.get("to_date"))) + except Exception: + pass + + sort_desc = bool(body.get("sort_desc", True)) + sort_by = body.get("sort_by") or "document_date" + sort_col = Document.document_date + if sort_by == 'code' and hasattr(Document, 'code'): + sort_col = Document.code + elif sort_by == 'created_at' and hasattr(Document, 'created_at'): + sort_col = Document.created_at + elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'): + sort_col = Document.registered_at + q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc()) + + docs: List[Document] = q.offset(skip_value).limit(take_value).all() + + def _type_name(tp: str) -> str: + mapping = { + 'invoice_sales': 'فروش', + 'invoice_sales_return': 'برگشت از فروش', + 'invoice_purchase': 'خرید', + 'invoice_purchase_return': 'برگشت از خرید', + 'invoice_direct_consumption': 'مصرف مستقیم', + 'invoice_production': 'تولید', + 'invoice_waste': 'ضایعات', + } + return mapping.get(str(tp), str(tp)) + + items: List[Dict[str, Any]] = [] + for d in docs: + item = invoice_document_to_dict(db, d) + total_amount = None + try: + totals = (item.get('extra_info') or {}).get('totals') or {} + if isinstance(totals, dict) and 'net' in totals: + total_amount = totals.get('net') + except Exception: + total_amount = None + if total_amount is None: + try: + net_sum = 0.0 + for pl in item.get('product_lines', []) or []: + info = pl.get('extra_info') or {} + qty = float(pl.get('quantity') or 0) + unit_price = float(info.get('unit_price') or 0) + line_discount = float(info.get('line_discount') or 0) + tax_amount = float(info.get('tax_amount') or 0) + line_total = info.get('line_total') + if line_total is None: + line_total = (qty * unit_price) - line_discount + tax_amount + net_sum += float(line_total) + total_amount = float(net_sum) + except Exception: + total_amount = None + item['document_type_name'] = _type_name(item.get('document_type')) + if total_amount is not None: + item['total_amount'] = total_amount + items.append(format_datetime_fields(item, request)) + + # Handle selected rows + selected_only = bool(body.get('selected_only', False)) + selected_indices = body.get('selected_indices') + if selected_only and selected_indices is not None: + indices = None + if isinstance(selected_indices, str): + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + elif isinstance(selected_indices, list): + indices = selected_indices + if isinstance(indices, list): + items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)] + + # Prepare columns + headers: List[str] = [] + keys: List[str] = [] + export_columns = body.get('export_columns') + if export_columns: + for col in export_columns: + key = col.get('key') + label = col.get('label', key) + if key: + keys.append(str(key)) + headers.append(str(label)) + else: + default_columns = [ + ('code', 'کد سند'), + ('document_type_name', 'نوع فاکتور'), + ('document_date', 'تاریخ سند'), + ('total_amount', 'مبلغ کل'), + ('currency_code', 'ارز'), + ('created_by_name', 'ایجادکننده'), + ('is_proforma', 'وضعیت'), + ('registered_at', 'تاریخ ثبت'), + ] + for key, label in default_columns: + if items and key in items[0]: + keys.append(key) + headers.append(label) + + # Business name & locale + business_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + business_name = b.name or "" + except Exception: + business_name = "" + + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = locale == 'fa' + now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + title_text = "لیست فاکتورها" if is_fa else "Invoices List" + label_biz = "کسب و کار" if is_fa else "Business" + label_date = "تاریخ تولید" if is_fa else "Generated Date" + footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}" + + headers_html = ''.join(f'{escape(header)}' for header in headers) + + rows_html = [] + for item in items: + row_cells = [] + for key in keys: + value = item.get(key, "") + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + elif isinstance(value, dict): + value = str(value) + row_cells.append(f'{escape(str(value))}') + rows_html.append(f'{"".join(row_cells)}') + + html_content = f""" + + + + + {title_text} + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+
+ + {headers_html} + {''.join(rows_html)} +
+
+ + + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config) + + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + + base = "invoices" + if business_name: + base += f"_{slugify(business_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + diff --git a/hesabixAPI/adapters/api/v1/schema_models/account.py b/hesabixAPI/adapters/api/v1/schema_models/account.py index 581b091..2405f44 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/account.py +++ b/hesabixAPI/adapters/api/v1/schema_models/account.py @@ -17,3 +17,17 @@ class AccountTreeNode(BaseModel): from_attributes = True +class AccountCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + code: str = Field(..., min_length=1, max_length=50) + account_type: str = Field(..., min_length=1, max_length=50) + parent_id: Optional[int] = Field(default=None) + + +class AccountUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + code: Optional[str] = Field(default=None, min_length=1, max_length=50) + account_type: Optional[str] = Field(default=None, min_length=1, max_length=50) + parent_id: Optional[int] = Field(default=None) + + diff --git a/hesabixAPI/adapters/api/v1/schema_models/product_bom.py b/hesabixAPI/adapters/api/v1/schema_models/product_bom.py index bdf5521..d15fe07 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/product_bom.py +++ b/hesabixAPI/adapters/api/v1/schema_models/product_bom.py @@ -124,3 +124,18 @@ class BOMExplosionResult(BaseModel): notes: Optional[str] = None +class ProductionDraftRequest(BaseModel): + product_id: Optional[int] = None + bom_id: Optional[int] = None + quantity: Decimal + document_date: Optional[str] = None + fiscal_year_id: Optional[int] = None + currency_id: Optional[int] = None + + +class ProductionDraftResponse(BaseModel): + document_type: str + description: Optional[str] = None + lines: List[dict] + extra_info: Optional[dict] = None + diff --git a/hesabixAPI/adapters/db/repositories/base_repo.py b/hesabixAPI/adapters/db/repositories/base_repo.py index ffb52b6..a73c866 100644 --- a/hesabixAPI/adapters/db/repositories/base_repo.py +++ b/hesabixAPI/adapters/db/repositories/base_repo.py @@ -59,6 +59,8 @@ class BaseRepository(Generic[T]): self.db.delete(obj) self.db.commit() - def update(self, obj: T) -> None: - """بروزرسانی رکورد در دیتابیس""" - self.db.commit() \ No newline at end of file + def update(self, obj: T) -> T: + """بروزرسانی رکورد در دیتابیس و برگرداندن شیء تازه‌سازی شده""" + self.db.commit() + self.db.refresh(obj) + return obj \ No newline at end of file diff --git a/hesabixAPI/adapters/db/repositories/document_repository.py b/hesabixAPI/adapters/db/repositories/document_repository.py index 1a0ce33..a7c8534 100644 --- a/hesabixAPI/adapters/db/repositories/document_repository.py +++ b/hesabixAPI/adapters/db/repositories/document_repository.py @@ -324,13 +324,13 @@ class DocumentRepository: line_dict["person_name"] = line.person.alias_name if line.product: - line_dict["product_name"] = line.product.title + line_dict["product_name"] = line.product.name if line.bank_account: - line_dict["bank_account_name"] = line.bank_account.account_title + line_dict["bank_account_name"] = line.bank_account.name if line.cash_register: - line_dict["cash_register_name"] = line.cash_register.title + line_dict["cash_register_name"] = line.cash_register.name if line.petty_cash: line_dict["petty_cash_name"] = line.petty_cash.name diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index 9e326b7..4796cfb 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -24,7 +24,10 @@ def require_app_permission(permission: str): if not ctx.has_app_permission(permission): raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) - return await func(*args, **kwargs) + result = func(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result return wrapper return decorator diff --git a/hesabixAPI/app/services/account_service.py b/hesabixAPI/app/services/account_service.py new file mode 100644 index 0000000..fb4e142 --- /dev/null +++ b/hesabixAPI/app/services/account_service.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from adapters.db.models.account import Account + + +def account_to_dict(obj: Account) -> Dict[str, Any]: + return { + "id": obj.id, + "name": obj.name, + "code": obj.code, + "account_type": obj.account_type, + "parent_id": obj.parent_id, + "business_id": obj.business_id, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + +def _validate_parent(db: Session, parent_id: Optional[int], business_id: Optional[int]) -> Optional[int]: + if parent_id is None: + return None + parent = db.get(Account, parent_id) + if not parent: + raise ValueError("PARENT_NOT_FOUND") + # والد باید عمومی یا متعلق به همان کسب‌وکار باشد + if parent.business_id is not None and parent.business_id != business_id: + raise ValueError("INVALID_PARENT_BUSINESS") + return parent.id + + +def create_account( + db: Session, + *, + name: str, + code: str, + account_type: str, + business_id: Optional[int], + parent_id: Optional[int] = None, +) -> Dict[str, Any]: + parent_id = _validate_parent(db, parent_id, business_id) + obj = Account( + name=name, + code=code, + account_type=account_type, + business_id=business_id, + parent_id=parent_id, + ) + db.add(obj) + try: + db.commit() + except IntegrityError as e: + db.rollback() + raise ValueError("ACCOUNT_CODE_NOT_UNIQUE") from e + db.refresh(obj) + return account_to_dict(obj) + + +def update_account( + db: Session, + account_id: int, + *, + name: Optional[str] = None, + code: Optional[str] = None, + account_type: Optional[str] = None, + parent_id: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + obj = db.get(Account, account_id) + if not obj: + return None + if parent_id is not None: + parent_id = _validate_parent(db, parent_id, obj.business_id) + if name is not None: + obj.name = name + if code is not None: + obj.code = code + if account_type is not None: + obj.account_type = account_type + obj.parent_id = parent_id if parent_id is not None else obj.parent_id + try: + db.commit() + except IntegrityError as e: + db.rollback() + raise ValueError("ACCOUNT_CODE_NOT_UNIQUE") from e + db.refresh(obj) + return account_to_dict(obj) + + +def delete_account(db: Session, account_id: int) -> bool: + obj = db.get(Account, account_id) + if not obj: + return False + db.delete(obj) + db.commit() + return True + + +def get_account(db: Session, account_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(Account, account_id) + return account_to_dict(obj) if obj else None + + diff --git a/hesabixAPI/app/services/bom_service.py b/hesabixAPI/app/services/bom_service.py index a647e78..9b05730 100644 --- a/hesabixAPI/app/services/bom_service.py +++ b/hesabixAPI/app/services/bom_service.py @@ -13,6 +13,7 @@ from adapters.api.v1.schema_models.product_bom import ( ProductBOMCreateRequest, ProductBOMUpdateRequest, BOMExplosionRequest, + ProductionDraftRequest, ) @@ -190,13 +191,53 @@ def explode_bom(db: Session, business_id: int, req: BOMExplosionRequest) -> Dict items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all() outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all() + # Prepare product lookup for enriching names/units in response + product_ids: set[int] = set([it.component_product_id for it in items] + [ot.output_product_id for ot in outputs]) + products_by_id: dict[int, Product] = {} + if product_ids: + for p in db.query(Product).filter(Product.id.in_(product_ids)).all(): + products_by_id[p.id] = p + qty = Decimal(str(req.quantity)) + + # Apply BOM-level wastage and yield to inputs + # factor_inputs scales required input quantities. Example: yield 80% => factor 1.25; wastage 5% => factor * 1.05 + factor_inputs = Decimal("1") + if bom.wastage_percent: + factor_inputs *= (Decimal("1.0") + Decimal(str(bom.wastage_percent)) / Decimal("100")) + if bom.yield_percent: + try: + y = Decimal(str(bom.yield_percent)) + if y > 0: + factor_inputs *= (Decimal("100") / y) + except Exception: + pass explosion_items: List[Dict[str, Any]] = [] for it in items: base = Decimal(str(it.qty_per)) * qty # apply line wastage if it.wastage_percent: base = base * (Decimal("1.0") + Decimal(str(it.wastage_percent)) / Decimal("100")) + # apply BOM-level factors + base = base * factor_inputs + prod = products_by_id.get(it.component_product_id) + + # Unit conversion to main unit (if BOM line uom equals secondary and factor exists) + required_qty_main_unit = None + main_unit = getattr(prod, "main_unit", None) if prod else None + secondary_unit = getattr(prod, "secondary_unit", None) if prod else None + unit_factor = getattr(prod, "unit_conversion_factor", None) if prod else None + if it.uom and prod and secondary_unit and main_unit and unit_factor is not None: + try: + # When line uom is secondary, convert to main by multiplying factor + if str(it.uom) == str(secondary_unit): + required_qty_main_unit = base * Decimal(str(unit_factor)) + elif str(it.uom) == str(main_unit): + required_qty_main_unit = base + else: + required_qty_main_unit = None + except Exception: + required_qty_main_unit = None explosion_items.append({ "component_product_id": it.component_product_id, "required_qty": base, @@ -204,18 +245,84 @@ def explode_bom(db: Session, business_id: int, req: BOMExplosionRequest) -> Dict "suggested_warehouse_id": it.suggested_warehouse_id, "is_optional": it.is_optional, "substitute_group": it.substitute_group, + # enriched (optional) fields for UI friendliness + "component_product_name": getattr(prod, "name", None) if prod else None, + "component_product_code": getattr(prod, "code", None) if prod else None, + "component_product_main_unit": getattr(prod, "main_unit", None) if prod else None, + "required_qty_main_unit": required_qty_main_unit, + "main_unit": main_unit, }) # outputs scaling out_scaled = [] for ot in outputs: + prod = products_by_id.get(ot.output_product_id) + # Convert output to main unit if needed + ratio_val = Decimal(str(ot.ratio)) * qty + ratio_main_unit = None + try: + main_unit = getattr(prod, "main_unit", None) if prod else None + secondary_unit = getattr(prod, "secondary_unit", None) if prod else None + unit_factor = getattr(prod, "unit_conversion_factor", None) if prod else None + if ot.uom and prod and secondary_unit and main_unit and unit_factor is not None: + if str(ot.uom) == str(secondary_unit): + ratio_main_unit = ratio_val * Decimal(str(unit_factor)) + elif str(ot.uom) == str(main_unit): + ratio_main_unit = ratio_val + except Exception: + ratio_main_unit = None out_scaled.append({ "line_no": ot.line_no, "output_product_id": ot.output_product_id, - "ratio": Decimal(str(ot.ratio)) * qty, + "ratio": ratio_val, "uom": ot.uom, + # enriched optional fields + "output_product_name": getattr(prod, "name", None) if prod else None, + "output_product_code": getattr(prod, "code", None) if prod else None, + "ratio_main_unit": ratio_main_unit, + "main_unit": getattr(prod, "main_unit", None) if prod else None, }) return {"items": explosion_items, "outputs": out_scaled} +def produce_draft(db: Session, business_id: int, req: ProductionDraftRequest) -> Dict[str, Any]: + """Create a draft payload for a production document based on BOM explosion (no persistence).""" + exp = explode_bom(db, business_id, BOMExplosionRequest(product_id=req.product_id, bom_id=req.bom_id, quantity=req.quantity)) + + # Build draft lines: for UI to prefill later; debit/credit left 0 to be set by user + lines: list[dict[str, Any]] = [] + for it in exp["items"]: + lines.append({ + "product_id": it["component_product_id"], + "quantity": it["required_qty"], + "debit": 0, + "credit": 0, + "description": f"مصرف مواد برای تولید", + "extra_info": { + "uom": it.get("uom"), + "suggested_warehouse_id": it.get("suggested_warehouse_id"), + "is_optional": it.get("is_optional"), + "substitute_group": it.get("substitute_group"), + }, + }) + + for ot in exp["outputs"]: + lines.append({ + "product_id": ot["output_product_id"], + "quantity": ot["ratio"], + "debit": 0, + "credit": 0, + "description": "خروجی تولید", + "extra_info": {"uom": ot.get("uom")}, + }) + + desc = "پیش‌نویس سند تولید بر اساس BOM" + return { + "document_type": "production", + "description": desc, + "lines": lines, + "extra_info": {"source": "bom", "bom_id": int(req.bom_id) if req.bom_id else None, "product_id": int(req.product_id) if req.product_id else None, "quantity": str(req.quantity)}, + } + + diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py new file mode 100644 index 0000000..bb3f26b --- /dev/null +++ b/hesabixAPI/app/services/invoice_service.py @@ -0,0 +1,1059 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime, date +from decimal import Decimal +import logging + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.account import Account +from adapters.db.models.currency import Currency +from adapters.db.models.user import User +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.person import Person +from adapters.db.models.product import Product +from app.core.responses import ApiError +import jdatetime + + +logger = logging.getLogger(__name__) + + +# Supported invoice types (Document.document_type) +INVOICE_SALES = "invoice_sales" +INVOICE_SALES_RETURN = "invoice_sales_return" +INVOICE_PURCHASE = "invoice_purchase" +INVOICE_PURCHASE_RETURN = "invoice_purchase_return" +INVOICE_DIRECT_CONSUMPTION = "invoice_direct_consumption" +INVOICE_PRODUCTION = "invoice_production" +INVOICE_WASTE = "invoice_waste" + +SUPPORTED_INVOICE_TYPES = { + INVOICE_SALES, + INVOICE_SALES_RETURN, + INVOICE_PURCHASE, + INVOICE_PURCHASE_RETURN, + INVOICE_DIRECT_CONSUMPTION, + INVOICE_PRODUCTION, + INVOICE_WASTE, +} + + +# --- Inventory & Costing helpers --- +def _get_costing_method(data: Dict[str, Any]) -> str: + try: + method = ((data.get("extra_info") or {}).get("costing_method") or "average").strip().lower() + if method not in ("average", "fifo"): + method = "average" + return method + except Exception: + return "average" + + +def _iter_product_movements( + db: Session, + business_id: int, + product_ids: List[int], + warehouse_ids: Optional[List[int]], + up_to_date: date, + exclude_document_id: Optional[int] = None, +): + """ + بازگرداندن حرکات موجودی (ورودی/خروجی) از اسناد قطعی تا تاریخ مشخص برای مجموعه کالا/انبار. + خروجی به ترتیب زمان/شناسه سند مرتب می‌شود. + """ + if not product_ids: + return [] + q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter( + and_( + Document.business_id == business_id, + Document.is_proforma == False, # noqa: E712 + Document.document_date <= up_to_date, + DocumentLine.product_id.in_(list({int(pid) for pid in product_ids})), + ) + ) + if exclude_document_id is not None: + q = q.filter(Document.id != int(exclude_document_id)) + rows = q.order_by(Document.document_date.asc(), Document.id.asc(), DocumentLine.id.asc()).all() + movements = [] + for line, doc in rows: + info = line.extra_info or {} + movement = (info.get("movement") or None) + wh_id = info.get("warehouse_id") + if movement is None: + # fallback از نوع سند اگر صراحتاً مشخص نشده باشد + inv_move, _ = _movement_from_type(doc.document_type) + movement = inv_move + if warehouse_ids and wh_id is not None and int(wh_id) not in warehouse_ids: + continue + if movement not in ("in", "out"): + continue + qty = Decimal(str(line.quantity or 0)) + if qty <= 0: + continue + cost_price = None + if info.get("cogs_amount") is not None and qty > 0 and movement == "out": + try: + cost_price = Decimal(str(info.get("cogs_amount"))) / qty + except Exception: + cost_price = None + if cost_price is None and info.get("cost_price") is not None: + cost_price = Decimal(str(info.get("cost_price"))) + if cost_price is None and info.get("unit_price") is not None: + cost_price = Decimal(str(info.get("unit_price"))) + movements.append({ + "document_id": doc.id, + "document_date": doc.document_date, + "product_id": line.product_id, + "warehouse_id": wh_id, + "movement": movement, + "quantity": qty, + "cost_price": cost_price, + }) + return movements + + +def _compute_available_stock( + db: Session, + business_id: int, + product_id: int, + warehouse_id: Optional[int], + up_to_date: date, + exclude_document_id: Optional[int] = None, +) -> Decimal: + movements = _iter_product_movements( + db, + business_id, + [product_id], + [int(warehouse_id)] if warehouse_id is not None else None, + up_to_date, + exclude_document_id, + ) + bal = Decimal(0) + for mv in movements: + if warehouse_id is not None and mv.get("warehouse_id") is not None and int(mv["warehouse_id"]) != int(warehouse_id): + continue + if mv["movement"] == "in": + bal += mv["quantity"] + elif mv["movement"] == "out": + bal -= mv["quantity"] + return bal + + +def _ensure_stock_sufficient( + db: Session, + business_id: int, + document_date: date, + outgoing_lines: List[Dict[str, Any]], + exclude_document_id: Optional[int] = None, +) -> None: + # تجمیع نیاز خروجی به تفکیک کالا/انبار + required: Dict[Tuple[int, Optional[int]], Decimal] = {} + for ln in outgoing_lines: + pid = int(ln.get("product_id")) + info = ln.get("extra_info") or {} + wh_id = info.get("warehouse_id") + qty = Decimal(str(ln.get("quantity", 0) or 0)) + key = (pid, int(wh_id) if wh_id is not None else None) + required[key] = required.get(key, Decimal(0)) + qty + + # بررسی موجودی + for (pid, wh_id), req in required.items(): + avail = _compute_available_stock(db, business_id, pid, wh_id, document_date, exclude_document_id) + if avail < req: + raise ApiError( + "INSUFFICIENT_STOCK", + f"موجودی کافی برای کالا {pid} در انبار {wh_id or '-'} موجود نیست. موجودی: {float(avail)}, موردنیاز: {float(req)}", + http_status=409, + ) + + +def _calculate_fifo_cogs_for_outgoing( + db: Session, + business_id: int, + document_date: date, + outgoing_lines: List[Dict[str, Any]], + exclude_document_id: Optional[int] = None, +) -> List[Decimal]: + """ + محاسبه COGS بر اساس FIFO برای لیست خطوط خروجی؛ خروجی به همان ترتیب ورودی است. + هر خط باید شامل product_id, quantity و extra_info.warehouse_id باشد. + """ + # گردآوری حرکات تاریخی همه کالاهای موردنیاز + product_ids = list({int(ln.get("product_id")) for ln in outgoing_lines}) + movements = _iter_product_movements(db, business_id, product_ids, None, document_date, exclude_document_id) + # ساخت لایه‌های FIFO به تفکیک کالا/انبار + from collections import defaultdict, deque + layers: Dict[Tuple[int, Optional[int]], deque] = defaultdict(deque) + for mv in movements: + key = (int(mv["product_id"]), int(mv["warehouse_id"]) if mv.get("warehouse_id") is not None else None) + if mv["movement"] == "in": + cost_price = mv.get("cost_price") or Decimal(0) + layers[key].append({"qty": Decimal(mv["quantity"]), "cost": Decimal(cost_price)}) + elif mv["movement"] == "out": + remain = Decimal(mv["quantity"]) + while remain > 0 and layers[key]: + top = layers[key][0] + take = min(remain, top["qty"]) + top["qty"] -= take + remain -= take + if top["qty"] <= 0: + layers[key].popleft() + # اگر خروجی تاریخی بیشتر از ورودی‌هاست، لایه‌ها منفی نشوند (کسری قبلی) + if remain > 0: + # اجازه کسری تاریخی: لایه منفی نمی‌سازیم، هزینه صفر می‌ماند + pass + + # محاسبه هزینه برای خطوط فعلی + results: List[Decimal] = [] + for ln in outgoing_lines: + pid = int(ln.get("product_id")) + qty = Decimal(str(ln.get("quantity", 0) or 0)) + info = ln.get("extra_info") or {} + wh_id = int(info.get("warehouse_id")) if info.get("warehouse_id") is not None else None + key = (pid, wh_id) + cost_total = Decimal(0) + remain = qty + temp_stack = [] + # مصرف از لایه‌ها + while remain > 0 and layers[key]: + top = layers[key][0] + take = min(remain, top["qty"]) + cost_total += take * Decimal(top["cost"] or 0) + top["qty"] -= take + remain -= take + temp_stack.append((take, top)) + if top["qty"] <= 0: + layers[key].popleft() + if remain > 0: + # اگر لایه کافی نبود، باقی‌مانده را با آخرین هزینه یا صفر حساب کنیم + last_cost = Decimal(0) + if temp_stack: + last_cost = Decimal(temp_stack[-1][1]["cost"] or 0) + cost_total += remain * last_cost + results.append(cost_total) + return results + + +def _parse_iso_date(dt: str | datetime | date) -> date: + if isinstance(dt, date): + return dt + if isinstance(dt, datetime): + return dt.date() + + dt_str = str(dt).strip() + + try: + dt_str_clean = dt_str.replace('Z', '+00:00') + parsed = datetime.fromisoformat(dt_str_clean) + return parsed.date() + except Exception: + pass + + try: + if len(dt_str) == 10 and dt_str.count('-') == 2: + return datetime.strptime(dt_str, '%Y-%m-%d').date() + except Exception: + pass + + try: + if len(dt_str) == 10 and dt_str.count('/') == 2: + parts = dt_str.split('/') + if len(parts) == 3: + year, month, day = parts + try: + year_int = int(year) + month_int = int(month) + day_int = int(day) + if year_int > 1500: + jalali_date = jdatetime.date(year_int, month_int, day_int) + gregorian_date = jalali_date.togregorian() + return gregorian_date + else: + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except (ValueError, jdatetime.JalaliDateError): + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except Exception: + pass + + raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400) + + +def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear: + fiscal_year = db.query(FiscalYear).filter( + and_( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True, + ) + ).first() + if not fiscal_year: + raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400) + return fiscal_year + + +def _get_fixed_account_by_code(db: Session, account_code: str) -> Account: + account = db.query(Account).filter( + and_( + Account.business_id == None, # noqa: E711 + Account.code == str(account_code), + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=500) + return account + + +def _get_person_control_account(db: Session) -> Account: + # عمومی اشخاص (پرداختنی/دریافتنی) پیش‌فرض: 20201 + return _get_fixed_account_by_code(db, "20201") + + +def _build_doc_code(prefix_base: str) -> str: + today = datetime.now().date() + prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}" + return prefix + + +def _extract_totals_from_lines(lines: List[Dict[str, Any]]) -> Dict[str, Decimal]: + gross = Decimal(0) + discount = Decimal(0) + tax = Decimal(0) + net = Decimal(0) + + for line in lines: + info = line.get("extra_info") or {} + qty = Decimal(str(line.get("quantity", 0) or 0)) + unit_price = Decimal(str(info.get("unit_price", 0) or 0)) + line_discount = Decimal(str(info.get("line_discount", 0) or 0)) + tax_amount = Decimal(str(info.get("tax_amount", 0) or 0)) + line_total = info.get("line_total") + if line_total is None: + line_total = (qty * unit_price) - line_discount + tax_amount + else: + line_total = Decimal(str(line_total)) + + gross += (qty * unit_price) + discount += line_discount + tax += tax_amount + net += line_total + + return { + "gross": gross, + "discount": discount, + "tax": tax, + "net": net, + } + + +def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal: + total = Decimal(0) + for line in lines: + info = line.get("extra_info") or {} + qty = Decimal(str(line.get("quantity", 0) or 0)) + if info.get("cogs_amount") is not None: + total += Decimal(str(info.get("cogs_amount"))) + continue + cost_price = info.get("cost_price") + if cost_price is not None: + total += (qty * Decimal(str(cost_price))) + continue + # fallback: use unit_price as cost if nothing provided + unit_price = info.get("unit_price") + if unit_price is not None: + total += (qty * Decimal(str(unit_price))) + return total + + +def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]: + # امکان override از extra_info.account_codes + overrides = ((data.get("extra_info") or {}).get("account_codes") or {}) + + def code(name: str, default_code: str) -> str: + return str(overrides.get(name) or default_code) + + return { + "revenue": _get_fixed_account_by_code(db, code("revenue", "70101")), + "sales_return": _get_fixed_account_by_code(db, code("sales_return", "70102")), + "inventory": _get_fixed_account_by_code(db, code("inventory", "10301")), + "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10302")), + "cogs": _get_fixed_account_by_code(db, code("cogs", "60101")), + "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20801")), + "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10801")), + "direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "60201")), + "wip": _get_fixed_account_by_code(db, code("wip", "60301")), + "waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "60401")), + "person": _get_person_control_account(db), + } + + +def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]: + try: + ei = data.get("extra_info") or {} + pid = ei.get("person_id") + return int(pid) if pid is not None else None + except Exception: + return None + + +def _movement_from_type(invoice_type: str) -> Tuple[Optional[str], Optional[str]]: + # Returns (movement_for_goods, reverse_movement) hints. Not strictly used for accounting. + if invoice_type == INVOICE_SALES: + return ("out", None) + if invoice_type == INVOICE_SALES_RETURN: + return ("in", None) + if invoice_type == INVOICE_PURCHASE: + return ("in", None) + if invoice_type == INVOICE_PURCHASE_RETURN: + return ("out", None) + if invoice_type in (INVOICE_DIRECT_CONSUMPTION, INVOICE_WASTE): + return ("out", None) + if invoice_type == INVOICE_PRODUCTION: + # production has both out (materials) and in (finished) + return (None, None) + return (None, None) + + +def _build_invoice_code(db: Session, business_id: int, invoice_type: str) -> str: + # INV-YYYYMMDD-NNNN (type agnostic); can be extended per-type later + prefix = _build_doc_code("INV") + last_doc = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.code.like(f"{prefix}-%"), + ) + ).order_by(Document.code.desc()).first() + + if last_doc: + try: + last_num = int(last_doc.code.split("-")[-1]) + next_num = last_num + 1 + except Exception: + next_num = 1 + else: + next_num = 1 + return f"{prefix}-{next_num:04d}" + + +def create_invoice( + db: Session, + business_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + logger.info("=== شروع ایجاد فاکتور ===") + + invoice_type = str(data.get("invoice_type", "")).strip() + if invoice_type not in SUPPORTED_INVOICE_TYPES: + raise ApiError("INVALID_INVOICE_TYPE", "Unsupported invoice_type", http_status=400) + + document_date = _parse_iso_date(data.get("document_date", datetime.now())) + currency_id = data.get("currency_id") + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + fiscal_year = _get_current_fiscal_year(db, business_id) + + lines_input: List[Dict[str, Any]] = list(data.get("lines") or []) + if not isinstance(lines_input, list) or len(lines_input) == 0: + raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400) + + # Basic person requirement for AR/AP invoices + person_id = _person_id_from_header(data) + if invoice_type in {INVOICE_SALES, INVOICE_SALES_RETURN, INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN} and not person_id: + raise ApiError("PERSON_REQUIRED", "person_id is required for this invoice type", http_status=400) + + # Compute totals from lines if not provided + header_extra = data.get("extra_info") or {} + totals = (header_extra.get("totals") or {}) if isinstance(header_extra, dict) else {} + totals_missing = not all(k in totals for k in ("gross", "discount", "tax", "net")) + if totals_missing: + totals = _extract_totals_from_lines(lines_input) + + # Inventory validation and costing pre-calculation + # Determine outgoing lines for stock checks + movement_hint, _ = _movement_from_type(invoice_type) + outgoing_lines: List[Dict[str, Any]] = [] + for ln in lines_input: + info = ln.get("extra_info") or {} + mv = info.get("movement") or movement_hint + if mv == "out": + outgoing_lines.append(ln) + + # Ensure stock sufficiency for outgoing + if outgoing_lines: + _ensure_stock_sufficient(db, business_id, document_date, outgoing_lines) + + # Costing method + costing_method = _get_costing_method(data) + if costing_method == "fifo" and outgoing_lines: + fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, outgoing_lines) + # annotate lines with cogs_amount in the same order as outgoing_lines + i = 0 + for ln in lines_input: + info = ln.get("extra_info") or {} + mv = info.get("movement") or movement_hint + if mv == "out": + amt = fifo_costs[i] + i += 1 + info = dict(info) + info["cogs_amount"] = float(amt) + ln["extra_info"] = info + + # Create document + doc_code = _build_invoice_code(db, business_id, invoice_type) + + # Enrich extra_info + new_extra_info = dict(header_extra) + new_extra_info["totals"] = { + "gross": float(Decimal(str(totals["gross"]))), + "discount": float(Decimal(str(totals["discount"]))), + "tax": float(Decimal(str(totals["tax"]))), + "net": float(Decimal(str(totals["net"]))), + } + + document = Document( + business_id=business_id, + fiscal_year_id=fiscal_year.id, + code=doc_code, + document_type=invoice_type, + document_date=document_date, + currency_id=int(currency_id), + created_by_user_id=user_id, + registered_at=datetime.utcnow(), + is_proforma=bool(data.get("is_proforma", False)), + description=data.get("description"), + extra_info=new_extra_info, + ) + db.add(document) + db.flush() + + # Create product lines (no debit/credit) + for line in lines_input: + product_id = line.get("product_id") + qty = Decimal(str(line.get("quantity", 0) or 0)) + if not product_id or qty <= 0: + raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) + extra_info = line.get("extra_info") or {} + db.add(DocumentLine( + document_id=document.id, + product_id=int(product_id), + quantity=qty, + debit=Decimal(0), + credit=Decimal(0), + description=line.get("description"), + extra_info=extra_info, + )) + + # Accounting lines for finalized invoices + if not document.is_proforma: + accounts = _resolve_accounts_for_invoice(db, data) + + net = Decimal(str(totals["gross"])) - Decimal(str(totals["discount"])) + tax = Decimal(str(totals["tax"])) + total_with_tax = net + tax + + # COGS when applicable + cogs_total = _extract_cogs_total(lines_input) + + # Sales + if invoice_type == INVOICE_SALES: + # AR (person) Dr, Revenue Cr, VAT out Cr, COGS Dr, Inventory Cr (optional) + if person_id: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["person"].id, + person_id=person_id, + debit=total_with_tax, + credit=Decimal(0), + description=data.get("description"), + extra_info={"side": "person", "person_id": person_id}, + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["revenue"].id, + debit=Decimal(0), + credit=net, + description="درآمد فروش", + )) + if tax > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["vat_out"].id, + debit=Decimal(0), + credit=tax, + description="مالیات بر ارزش افزوده خروجی", + )) + if cogs_total > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["cogs"].id, + debit=cogs_total, + credit=Decimal(0), + description="بهای تمام‌شده کالای فروش‌رفته", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=Decimal(0), + credit=cogs_total, + description="خروج از موجودی بابت فروش", + )) + + # Sales Return + elif invoice_type == INVOICE_SALES_RETURN: + if person_id: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["person"].id, + person_id=person_id, + debit=Decimal(0), + credit=total_with_tax, + description=data.get("description"), + extra_info={"side": "person", "person_id": person_id}, + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["sales_return"].id, + debit=net, + credit=Decimal(0), + description="برگشت از فروش", + )) + if tax > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["vat_in"].id, + debit=tax, + credit=Decimal(0), + description="تعدیل VAT برگشت از فروش", + )) + if cogs_total > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=cogs_total, + credit=Decimal(0), + description="ورود به موجودی بابت برگشت", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["cogs"].id, + debit=Decimal(0), + credit=cogs_total, + description="تعدیل بهای تمام‌شده برگشت", + )) + + # Purchase + elif invoice_type == INVOICE_PURCHASE: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=net, + credit=Decimal(0), + description="ورود به موجودی بابت خرید", + )) + if tax > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["vat_in"].id, + debit=tax, + credit=Decimal(0), + description="مالیات بر ارزش افزوده ورودی", + )) + if person_id: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["person"].id, + person_id=person_id, + debit=Decimal(0), + credit=total_with_tax, + description=data.get("description"), + extra_info={"side": "person", "person_id": person_id}, + )) + + # Purchase Return + elif invoice_type == INVOICE_PURCHASE_RETURN: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=Decimal(0), + credit=net, + description="خروج از موجودی بابت برگشت خرید", + )) + if tax > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["vat_in"].id, + debit=Decimal(0), + credit=tax, + description="تعدیل VAT ورودی برگشت خرید", + )) + if person_id: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["person"].id, + person_id=person_id, + debit=total_with_tax, + credit=Decimal(0), + description=data.get("description"), + extra_info={"side": "person", "person_id": person_id}, + )) + + # Direct consumption + elif invoice_type == INVOICE_DIRECT_CONSUMPTION: + if cogs_total > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["direct_consumption"].id, + debit=cogs_total, + credit=Decimal(0), + description="مصرف مستقیم", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=Decimal(0), + credit=cogs_total, + description="خروج از موجودی بابت مصرف", + )) + + # Waste + elif invoice_type == INVOICE_WASTE: + if cogs_total > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["waste_expense"].id, + debit=cogs_total, + credit=Decimal(0), + description="ضایعات", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=Decimal(0), + credit=cogs_total, + description="خروج از موجودی بابت ضایعات", + )) + + # Production (WIP) + elif invoice_type == INVOICE_PRODUCTION: + # materials (out) → Debit WIP, Credit Inventory + materials_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "out"]) + if materials_cost > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["wip"].id, + debit=materials_cost, + credit=Decimal(0), + description="انتقال مواد به کاردرجریان", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory"].id, + debit=Decimal(0), + credit=materials_cost, + description="خروج مواد اولیه", + )) + # finished goods (in) → Debit Finished Inventory, Credit WIP + finished_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "in"]) + if finished_cost > 0: + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["inventory_finished"].id, + debit=finished_cost, + credit=Decimal(0), + description="ورود کالای ساخته‌شده", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=accounts["wip"].id, + debit=Decimal(0), + credit=finished_cost, + description="انتقال از کاردرجریان", + )) + + # Persist invoice first + db.commit() + db.refresh(document) + + # Optional: create receipt/payment document(s) + payment_docs: List[int] = [] + payments = data.get("payments") or [] + if payments and isinstance(payments, list): + try: + # Only when person is present + if person_id: + from app.services.receipt_payment_service import create_receipt_payment + + # Aggregate amounts into one receipt/payment with multiple account_lines + account_lines: List[Dict[str, Any]] = [] + total_amount = Decimal(0) + for p in payments: + amount = Decimal(str(p.get("amount", 0) or 0)) + if amount <= 0: + continue + total_amount += amount + account_lines.append({ + "transaction_type": p.get("transaction_type"), + "amount": float(amount), + "description": p.get("description"), + "transaction_date": p.get("transaction_date"), + "commission": p.get("commission"), + }) + + if total_amount > 0 and account_lines: + is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN} + rp_data = { + "document_type": "receipt" if is_receipt else "payment", + "document_date": document.document_date.isoformat(), + "currency_id": document.currency_id, + "description": f"تسویه مرتبط با فاکتور {document.code}", + "person_lines": [{ + "person_id": person_id, + "amount": float(total_amount), + "description": f"طرف حساب فاکتور {document.code}", + }], + "account_lines": account_lines, + "extra_info": {"source": "invoice", "invoice_id": document.id}, + } + rp_doc = create_receipt_payment(db=db, business_id=business_id, user_id=user_id, data=rp_data) + if isinstance(rp_doc, dict) and rp_doc.get("id"): + payment_docs.append(int(rp_doc["id"])) + except Exception as ex: + logger.exception("could not create receipt/payment for invoice: %s", ex) + + # Save links back to invoice + if payment_docs: + extra = document.extra_info or {} + links = dict((extra.get("links") or {})) + links["receipt_payment_document_ids"] = payment_docs + extra["links"] = links + document.extra_info = extra + db.commit() + db.refresh(document) + + return invoice_document_to_dict(db, document) + + +def update_invoice( + db: Session, + document_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + document = db.query(Document).filter(Document.id == document_id).first() + if not document or document.document_type not in SUPPORTED_INVOICE_TYPES: + raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404) + + # Only editable in current fiscal year + try: + fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first() + if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True: + raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد", http_status=409) + except ApiError: + raise + except Exception: + pass + + # Update header + document_date = _parse_iso_date(data.get("document_date", document.document_date)) + currency_id = data.get("currency_id", document.currency_id) + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + document.document_date = document_date + document.currency_id = int(currency_id) + if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None: + # preserve links if present + new_extra = data.get("extra_info") or {} + old_extra = document.extra_info or {} + links = old_extra.get("links") + if links and "links" not in new_extra: + new_extra["links"] = links + document.extra_info = new_extra + if isinstance(data.get("description"), str) or data.get("description") is None: + if data.get("description") is not None: + document.description = data.get("description") + + # Recreate lines + db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False) + + lines_input: List[Dict[str, Any]] = list(data.get("lines") or []) + if not lines_input: + raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400) + + # Inventory validation and costing before re-adding lines + inv_type = document.document_type + movement_hint, _ = _movement_from_type(inv_type) + outgoing_lines: List[Dict[str, Any]] = [] + for ln in lines_input: + info = ln.get("extra_info") or {} + mv = info.get("movement") or movement_hint + if mv == "out": + outgoing_lines.append(ln) + + if outgoing_lines: + _ensure_stock_sufficient(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) + + header_for_costing = data if data else {"extra_info": document.extra_info} + costing_method = _get_costing_method(header_for_costing) + if costing_method == "fifo" and outgoing_lines: + fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) + i = 0 + for ln in lines_input: + info = ln.get("extra_info") or {} + mv = info.get("movement") or movement_hint + if mv == "out": + amt = fifo_costs[i] + i += 1 + info = dict(info) + info["cogs_amount"] = float(amt) + ln["extra_info"] = info + + for line in lines_input: + product_id = line.get("product_id") + qty = Decimal(str(line.get("quantity", 0) or 0)) + if not product_id or qty <= 0: + raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) + extra_info = line.get("extra_info") or {} + db.add(DocumentLine( + document_id=document.id, + product_id=int(product_id), + quantity=qty, + debit=Decimal(0), + credit=Decimal(0), + description=line.get("description"), + extra_info=extra_info, + )) + + # Accounting lines if finalized + if not document.is_proforma: + accounts = _resolve_accounts_for_invoice(db, data if data else {"extra_info": document.extra_info}) + header_extra = data.get("extra_info") or document.extra_info or {} + totals = (header_extra.get("totals") or {}) + if not totals: + totals = _extract_totals_from_lines(lines_input) + net = Decimal(str(totals.get("gross", 0))) - Decimal(str(totals.get("discount", 0))) + tax = Decimal(str(totals.get("tax", 0))) + total_with_tax = net + tax + person_id = _person_id_from_header({"extra_info": header_extra}) + cogs_total = _extract_cogs_total(lines_input) + + if inv_type == INVOICE_SALES: + if person_id: + db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=total_with_tax, credit=Decimal(0), description=document.description)) + db.add(DocumentLine(document_id=document.id, account_id=accounts["revenue"].id, debit=Decimal(0), credit=net, description="درآمد فروش")) + if tax > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی")) + if cogs_total > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=cogs_total, credit=Decimal(0), description="بهای تمام‌شده")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی")) + elif inv_type == INVOICE_SALES_RETURN: + if person_id: + db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description)) + db.add(DocumentLine(document_id=document.id, account_id=accounts["sales_return"].id, debit=net, credit=Decimal(0), description="برگشت از فروش")) + if tax > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT")) + if cogs_total > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=cogs_total, credit=Decimal(0), description="ورود موجودی")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=Decimal(0), credit=cogs_total, description="تعدیل بهای تمام‌شده")) + elif inv_type == INVOICE_PURCHASE: + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=net, credit=Decimal(0), description="ورود موجودی")) + if tax > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="مالیات ورودی")) + if person_id: + db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description)) + elif inv_type == INVOICE_PURCHASE_RETURN: + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=net, description="خروج موجودی")) + if tax > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=Decimal(0), credit=tax, description="تعدیل VAT ورودی")) + if person_id: + db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=total_with_tax, credit=Decimal(0), description=document.description)) + elif inv_type == INVOICE_DIRECT_CONSUMPTION: + if cogs_total > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["direct_consumption"].id, debit=cogs_total, credit=Decimal(0), description="مصرف مستقیم")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی")) + elif inv_type == INVOICE_WASTE: + if cogs_total > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["waste_expense"].id, debit=cogs_total, credit=Decimal(0), description="ضایعات")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی")) + elif inv_type == INVOICE_PRODUCTION: + materials_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "out"]) + if materials_cost > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=materials_cost, credit=Decimal(0), description="انتقال به کاردرجریان")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=materials_cost, description="خروج مواد")) + finished_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "in"]) + if finished_cost > 0: + db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساخته‌شده")) + db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان")) + + db.commit() + db.refresh(document) + return invoice_document_to_dict(db, document) + + +def invoice_document_to_dict(db: Session, document: Document) -> Dict[str, Any]: + lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all() + + product_lines: List[Dict[str, Any]] = [] + account_lines: List[Dict[str, Any]] = [] + + for line in lines: + if line.product_id: + product = db.query(Product).filter(Product.id == line.product_id).first() + product_lines.append({ + "id": line.id, + "product_id": line.product_id, + "product_name": getattr(product, "name", None), + "quantity": float(line.quantity) if line.quantity else None, + "description": line.description, + "extra_info": line.extra_info, + }) + elif line.account_id: + account = db.query(Account).filter(Account.id == line.account_id).first() + account_lines.append({ + "id": line.id, + "account_id": line.account_id, + "account_name": getattr(account, "name", None), + "account_code": getattr(account, "code", None), + "debit": float(line.debit), + "credit": float(line.credit), + "person_id": line.person_id, + "description": line.description, + "extra_info": line.extra_info, + }) + + created_by = db.query(User).filter(User.id == document.created_by_user_id).first() + created_by_name = f"{getattr(created_by, 'first_name', '')} {getattr(created_by, 'last_name', '')}".strip() if created_by else None + currency = db.query(Currency).filter(Currency.id == document.currency_id).first() + + return { + "id": document.id, + "code": document.code, + "business_id": document.business_id, + "document_type": document.document_type, + "document_date": document.document_date.isoformat(), + "registered_at": document.registered_at.isoformat(), + "currency_id": document.currency_id, + "currency_code": getattr(currency, "code", None), + "created_by_user_id": document.created_by_user_id, + "created_by_name": created_by_name, + "is_proforma": document.is_proforma, + "description": document.description, + "extra_info": document.extra_info, + "product_lines": product_lines, + "account_lines": account_lines, + "created_at": document.created_at.isoformat(), + "updated_at": document.updated_at.isoformat(), + } + + diff --git a/hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md b/hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md new file mode 100644 index 0000000..09ba614 --- /dev/null +++ b/hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md @@ -0,0 +1,432 @@ +# مشکلات و سناریوهای ثبت فاکتور + +## 🔴 مشکلات شناسایی شده + +### 1. **عدم تطابق نام فیلد نوع فاکتور** +- **مشکل**: در UI از `type` استفاده می‌شود اما API از `invoice_type` انتظار دارد +- **مکان**: `new_invoice_page.dart` خط 801 +- **وضعیت فعلی**: `'type': _selectedInvoiceType!.value` (مثل `'sales'`, `'purchase'`) +- **وضعیت مورد انتظار**: `'invoice_type': 'invoice_sales'`, `'invoice_purchase'`, و غیره + +### 2. **عدم تطابق فرمت نوع فاکتور** +- **مشکل**: UI مقادیر ساده ارسال می‌کند (`'sales'`) اما API فرمت کامل می‌خواهد (`'invoice_sales'`) +- **مکان**: تبدیل از `InvoiceType.value` به فرمت API +- **مثال**: `'sales'` باید به `'invoice_sales'` تبدیل شود + +### 3. **عدم استخراج person_id از customer/seller** +- **مشکل**: API فقط `extra_info.person_id` را می‌خواند اما UI `customer_id` و `seller_id` را ارسال می‌کند +- **مکان**: `invoice_service.py` خط 393-399 (`_person_id_from_header`) +- **وضعیت فعلی**: `customer_id` و `seller_id` در payload هستند اما API آن‌ها را نمی‌خواند + +### 4. **عدم تطابق نام فیلد تاریخ** +- **مشکل**: UI از `invoice_date` استفاده می‌کند اما API از `document_date` انتظار دارد +- **مکان**: `new_invoice_page.dart` خط 804 + +### 5. **عدم تطابق ساختار خطوط** +- **مشکل**: UI از `line_items` استفاده می‌کند اما API از `lines` انتظار دارد +- **مکان**: `new_invoice_page.dart` خط 823 + +### 6. **عدم وجود فیلد تامین‌کننده برای فاکتور خرید** +- **مشکل**: برای فاکتورهای خرید و برگشت از خرید، باید تامین‌کننده (supplier) انتخاب شود نه مشتری +- **مکان**: UI فقط `CustomerPickerWidget` دارد که برای خرید مناسب نیست + +### 7. **عدم ارسال person_id در extra_info** +- **مشکل**: `person_id` باید در `extra_info` ارسال شود نه به صورت مستقیم +- **مکان**: `new_invoice_page.dart` - ساخت payload + +--- + +## 📋 سناریوهای صحیح برای هر نوع فاکتور + +### ✅ فاکتور فروش (Sales Invoice) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_sales` +- ✅ مشتری (Customer): الزامی - باید به `person_id` تبدیل شود +- ✅ فروشنده/بازاریاب (Seller): اختیاری - فقط برای کارمزد +- ✅ تراکنش‌ها: دریافت (Receipt) - اختیاری +- ✅ تاریخ فاکتور: `document_date` +- ✅ تاریخ سررسید: `due_date` (اختیاری) + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_sales", + "document_date": "2024-01-15", + "due_date": "2024-02-15", + "currency_id": 1, + "is_proforma": false, + "description": "فروش محصولات", + "extra_info": { + "person_id": 123, // از customer_id استخراج شود + "seller_id": 456, // اختیاری + "commission": { + "type": "percentage", + "value": 5.5 + }, + "totals": { + "gross": 1000000, + "discount": 50000, + "tax": 95000, + "net": 1045000 + } + }, + "lines": [ + { + "product_id": 1, + "quantity": 10, + "extra_info": { + "unit_price": 100000, + "line_discount": 5000, + "tax_amount": 9500, + "movement": "out" + } + } + ], + "payments": [ + { + "transaction_type": "cash", + "amount": 500000, + "transaction_date": "2024-01-15" + } + ] +} +``` + +--- + +### ✅ فاکتور برگشت از فروش (Sales Return) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_sales_return` +- ✅ مشتری (Customer): الزامی - همان مشتری فاکتور اصلی +- ✅ فروشنده/بازاریاب: اختیاری - همان فروشنده اصلی +- ✅ تراکنش‌ها: پرداخت (Payment) - اختیاری +- ✅ تاریخ فاکتور: `document_date` + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_sales_return", + "document_date": "2024-01-20", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "person_id": 123, // از customer_id استخراج شود + "totals": { + "gross": 500000, + "discount": 0, + "tax": 47500, + "net": 547500 + } + }, + "lines": [ + { + "product_id": 1, + "quantity": 5, + "extra_info": { + "unit_price": 100000, + "movement": "in" + } + } + ] +} +``` + +--- + +### ✅ فاکتور خرید (Purchase Invoice) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_purchase` +- ✅ **تامین‌کننده (Supplier)**: الزامی - باید `PersonPickerWidget` با فیلتر `person_types: ['تامین‌کننده', 'فروشنده']` باشد +- ✅ تراکنش‌ها: پرداخت (Payment) - اختیاری +- ✅ تاریخ فاکتور: `document_date` + +**مشکل فعلی:** UI فقط `CustomerPickerWidget` دارد که برای خرید مناسب نیست! + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_purchase", + "document_date": "2024-01-15", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "person_id": 789, // از supplier_id استخراج شود + "totals": { + "gross": 2000000, + "discount": 100000, + "tax": 190000, + "net": 2090000 + } + }, + "lines": [ + { + "product_id": 2, + "quantity": 20, + "extra_info": { + "unit_price": 100000, + "movement": "in" + } + } + ], + "payments": [ + { + "transaction_type": "cash", + "amount": 1000000, + "transaction_date": "2024-01-15" + } + ] +} +``` + +--- + +### ✅ فاکتور برگشت از خرید (Purchase Return) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_purchase_return` +- ✅ **تامین‌کننده (Supplier)**: الزامی - همان تامین‌کننده فاکتور خرید اصلی +- ✅ تراکنش‌ها: دریافت (Receipt) - اختیاری +- ✅ تاریخ فاکتور: `document_date` + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_purchase_return", + "document_date": "2024-01-20", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "person_id": 789, // از supplier_id استخراج شود + "totals": { + "gross": 500000, + "discount": 0, + "tax": 47500, + "net": 547500 + } + }, + "lines": [ + { + "product_id": 2, + "quantity": 5, + "extra_info": { + "unit_price": 100000, + "movement": "out" + } + } + ] +} +``` + +--- + +### ✅ فاکتور مصرف مستقیم (Direct Consumption) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_direct_consumption` +- ❌ مشتری/تامین‌کننده: نیاز ندارد +- ❌ تراکنش‌ها: نیاز ندارد +- ✅ تاریخ فاکتور: `document_date` + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_direct_consumption", + "document_date": "2024-01-15", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "totals": { + "gross": 0, + "discount": 0, + "tax": 0, + "net": 0 + } + }, + "lines": [ + { + "product_id": 3, + "quantity": 5, + "extra_info": { + "movement": "out" + } + } + ] +} +``` + +--- + +### ✅ فاکتور ضایعات (Waste) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_waste` +- ❌ مشتری/تامین‌کننده: نیاز ندارد +- ❌ تراکنش‌ها: نیاز ندارد +- ✅ تاریخ فاکتور: `document_date` + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_waste", + "document_date": "2024-01-15", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "totals": { + "gross": 0, + "discount": 0, + "tax": 0, + "net": 0 + } + }, + "lines": [ + { + "product_id": 4, + "quantity": 2, + "extra_info": { + "movement": "out" + } + } + ] +} +``` + +--- + +### ✅ فاکتور تولید (Production) +**فیلدهای مورد نیاز:** +- ✅ نوع فاکتور: `invoice_production` +- ❌ مشتری/تامین‌کننده: نیاز ندارد +- ❌ تراکنش‌ها: نیاز ندارد +- ✅ تاریخ فاکتور: `document_date` +- ✅ خطوط خروجی (مواد اولیه): `movement: "out"` +- ✅ خطوط ورودی (کالای ساخته شده): `movement: "in"` + +**ساختار payload صحیح:** +```json +{ + "invoice_type": "invoice_production", + "document_date": "2024-01-15", + "currency_id": 1, + "is_proforma": false, + "extra_info": { + "totals": { + "gross": 0, + "discount": 0, + "tax": 0, + "net": 0 + } + }, + "lines": [ + { + "product_id": 5, // مواد اولیه + "quantity": 10, + "extra_info": { + "movement": "out" + } + }, + { + "product_id": 6, // کالای ساخته شده + "quantity": 5, + "extra_info": { + "movement": "in" + } + } + ] +} +``` + +--- + +## 🔧 راه‌حل‌های پیشنهادی + +### 1. تبدیل صحیح نوع فاکتور +```dart +String _convertInvoiceTypeToApi(InvoiceType type) { + return 'invoice_${type.value}'; +} +``` + +### 2. استخراج person_id از customer/seller +```dart +// در ساخت payload: +if (_selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.salesReturn) { + // برای فروش: person_id از customer + if (_selectedCustomer != null) { + extraInfo['person_id'] = _selectedCustomer!.id; + } +} + +if (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.purchaseReturn) { + // برای خرید: person_id از supplier + if (_selectedSupplier != null) { + extraInfo['person_id'] = _selectedSupplier!.id; + } +} +``` + +### 3. افزودن PersonPickerWidget برای فاکتور خرید +```dart +// در _buildInvoiceInfoTab: +if (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.purchaseReturn) { + PersonPickerWidget( + personTypes: ['تامین‌کننده', 'فروشنده'], + selectedPerson: _selectedSupplier, + onChanged: (person) { + setState(() { + _selectedSupplier = person; + }); + }, + ), +} +``` + +### 4. تبدیل نام فیلدها +```dart +final payload = { + 'invoice_type': _convertInvoiceTypeToApi(_selectedInvoiceType!), + 'document_date': _invoiceDate!.toIso8601String().split('T')[0], + // ... + 'lines': _lineItems.map((e) => _serializeLineItem(e)).toList(), + 'extra_info': { + if (_selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.salesReturn) + if (_selectedCustomer != null) + 'person_id': _selectedCustomer!.id, + if (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.purchaseReturn) + if (_selectedSupplier != null) + 'person_id': _selectedSupplier!.id, + 'totals': { + 'gross': _sumSubtotal, + 'discount': _sumDiscount, + 'tax': _sumTax, + 'net': _sumTotal, + }, + }, +}; +``` + +--- + +## 📝 خلاصه + +### فاکتورهای نیازمند مشتری (Customer): +- ✅ فاکتور فروش +- ✅ فاکتور برگشت از فروش + +### فاکتورهای نیازمند تامین‌کننده (Supplier): +- ✅ فاکتور خرید +- ✅ فاکتور برگشت از خرید + +### فاکتورهای بدون person: +- ✅ فاکتور مصرف مستقیم +- ✅ فاکتور ضایعات +- ✅ فاکتور تولید + +### فاکتورهای نیازمند تراکنش: +- ✅ فاکتور فروش (Receipt) +- ✅ فاکتور برگشت از فروش (Payment) +- ✅ فاکتور خرید (Payment) +- ✅ فاکتور برگشت از خرید (Receipt) + diff --git a/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py b/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py index 6fd6e31..69fa738 100644 --- a/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py +++ b/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py @@ -19,95 +19,118 @@ depends_on = None def upgrade() -> None: - # warehouses - op.create_table( - "warehouses", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("code", sa.String(length=64), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), - sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"), - ) - op.create_index("ix_warehouses_business_id", "warehouses", ["business_id"]) - op.create_index("ix_warehouses_code", "warehouses", ["code"]) - op.create_index("ix_warehouses_name", "warehouses", ["name"]) - op.create_index("ix_warehouses_is_default", "warehouses", ["is_default"]) + bind = op.get_bind() + insp = sa.inspect(bind) + + # warehouses (ایجاد فقط اگر وجود ندارد) + if not insp.has_table("warehouses"): + op.create_table( + "warehouses", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"), + ) + try: + op.create_index("ix_warehouses_business_id", "warehouses", ["business_id"]) + op.create_index("ix_warehouses_code", "warehouses", ["code"]) + op.create_index("ix_warehouses_name", "warehouses", ["name"]) + op.create_index("ix_warehouses_is_default", "warehouses", ["is_default"]) + except Exception: + pass # product_boms - op.create_table( - "product_boms", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False), - sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), - sa.Column("version", sa.String(length=64), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), - sa.Column("effective_from", sa.Date(), nullable=True), - sa.Column("effective_to", sa.Date(), nullable=True), - sa.Column("yield_percent", sa.Numeric(5, 2), nullable=True), - sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), - sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'draft'")), - sa.Column("notes", sa.Text(), nullable=True), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"), - ) - op.create_index("ix_product_boms_business_id", "product_boms", ["business_id"]) - op.create_index("ix_product_boms_product_id", "product_boms", ["product_id"]) - op.create_index("ix_product_boms_is_default", "product_boms", ["is_default"]) - op.create_index("ix_product_boms_status", "product_boms", ["status"]) + if not insp.has_table("product_boms"): + op.create_table( + "product_boms", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("version", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("effective_from", sa.Date(), nullable=True), + sa.Column("effective_to", sa.Date(), nullable=True), + sa.Column("yield_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'draft'")), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"), + ) + try: + op.create_index("ix_product_boms_business_id", "product_boms", ["business_id"]) + op.create_index("ix_product_boms_product_id", "product_boms", ["product_id"]) + op.create_index("ix_product_boms_is_default", "product_boms", ["is_default"]) + op.create_index("ix_product_boms_status", "product_boms", ["status"]) + except Exception: + pass # product_bom_items - op.create_table( - "product_bom_items", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), - sa.Column("line_no", sa.Integer(), nullable=False), - sa.Column("component_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), - sa.Column("qty_per", sa.Numeric(18, 6), nullable=False), - sa.Column("uom", sa.String(length=32), nullable=True), - sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), - sa.Column("is_optional", sa.Boolean(), nullable=False, server_default=sa.text("0")), - sa.Column("substitute_group", sa.String(length=64), nullable=True), - sa.Column("suggested_warehouse_id", sa.Integer(), sa.ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True), - sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"), - ) - op.create_index("ix_product_bom_items_bom_id", "product_bom_items", ["bom_id"]) - op.create_index("ix_product_bom_items_component_product_id", "product_bom_items", ["component_product_id"]) + if not insp.has_table("product_bom_items"): + op.create_table( + "product_bom_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("component_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), + sa.Column("qty_per", sa.Numeric(18, 6), nullable=False), + sa.Column("uom", sa.String(length=32), nullable=True), + sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("is_optional", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("substitute_group", sa.String(length=64), nullable=True), + sa.Column("suggested_warehouse_id", sa.Integer(), sa.ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"), + ) + try: + op.create_index("ix_product_bom_items_bom_id", "product_bom_items", ["bom_id"]) + op.create_index("ix_product_bom_items_component_product_id", "product_bom_items", ["component_product_id"]) + except Exception: + pass # product_bom_outputs - op.create_table( - "product_bom_outputs", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), - sa.Column("line_no", sa.Integer(), nullable=False), - sa.Column("output_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), - sa.Column("ratio", sa.Numeric(18, 6), nullable=False), - sa.Column("uom", sa.String(length=32), nullable=True), - sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"), - ) - op.create_index("ix_product_bom_outputs_bom_id", "product_bom_outputs", ["bom_id"]) - op.create_index("ix_product_bom_outputs_output_product_id", "product_bom_outputs", ["output_product_id"]) + if not insp.has_table("product_bom_outputs"): + op.create_table( + "product_bom_outputs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("output_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), + sa.Column("ratio", sa.Numeric(18, 6), nullable=False), + sa.Column("uom", sa.String(length=32), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"), + ) + try: + op.create_index("ix_product_bom_outputs_bom_id", "product_bom_outputs", ["bom_id"]) + op.create_index("ix_product_bom_outputs_output_product_id", "product_bom_outputs", ["output_product_id"]) + except Exception: + pass # product_bom_operations - op.create_table( - "product_bom_operations", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), - sa.Column("line_no", sa.Integer(), nullable=False), - sa.Column("operation_name", sa.String(length=255), nullable=False), - sa.Column("cost_fixed", sa.Numeric(18, 2), nullable=True), - sa.Column("cost_per_unit", sa.Numeric(18, 6), nullable=True), - sa.Column("cost_uom", sa.String(length=32), nullable=True), - sa.Column("work_center", sa.String(length=128), nullable=True), - sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"), - ) - op.create_index("ix_product_bom_operations_bom_id", "product_bom_operations", ["bom_id"]) + if not insp.has_table("product_bom_operations"): + op.create_table( + "product_bom_operations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("operation_name", sa.String(length=255), nullable=False), + sa.Column("cost_fixed", sa.Numeric(18, 2), nullable=True), + sa.Column("cost_per_unit", sa.Numeric(18, 6), nullable=True), + sa.Column("cost_uom", sa.String(length=32), nullable=True), + sa.Column("work_center", sa.String(length=128), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"), + ) + try: + op.create_index("ix_product_bom_operations_bom_id", "product_bom_operations", ["bom_id"]) + except Exception: + pass def downgrade() -> None: diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 7bd2925..5e45ee7 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -25,9 +25,10 @@ import 'pages/business/users_permissions_page.dart'; import 'pages/business/accounts_page.dart'; import 'pages/business/bank_accounts_page.dart'; import 'pages/business/wallet_page.dart'; -import 'pages/business/invoice_page.dart'; +import 'pages/business/invoices_list_page.dart'; import 'pages/business/new_invoice_page.dart'; import 'pages/business/settings_page.dart'; +import 'pages/business/business_info_settings_page.dart'; import 'pages/business/reports_page.dart'; import 'pages/business/persons_page.dart'; import 'pages/business/product_attributes_page.dart'; @@ -595,9 +596,11 @@ class _MyAppState extends State { pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); return NoTransitionPage( - child: InvoicePage( + child: InvoicesListPage( businessId: businessId, + calendarController: _calendarController!, authStore: _authStore!, + apiClient: ApiClient(), ), ); }, @@ -650,6 +653,21 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: '/business/:business_id/settings/business', + name: 'business_settings_business', + pageBuilder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + if (!_authStore!.hasBusinessPermission('settings', 'join')) { + return NoTransitionPage( + child: PermissionGuard.buildAccessDeniedPage(), + ); + } + return NoTransitionPage( + child: BusinessInfoSettingsPage(businessId: businessId), + ); + }, + ), GoRoute( path: '/business/:business_id/product-attributes', name: 'business_product_attributes', diff --git a/hesabixUI/hesabix_ui/lib/models/bom_models.dart b/hesabixUI/hesabix_ui/lib/models/bom_models.dart index bc61683..74d2c1a 100644 --- a/hesabixUI/hesabix_ui/lib/models/bom_models.dart +++ b/hesabixUI/hesabix_ui/lib/models/bom_models.dart @@ -51,12 +51,20 @@ class BomOutput { final int outputProductId; final double ratio; final String? uom; + final String? outputProductName; + final String? outputProductCode; + final double? ratioMainUnit; + final String? mainUnit; const BomOutput({ required this.lineNo, required this.outputProductId, required this.ratio, this.uom, + this.outputProductName, + this.outputProductCode, + this.ratioMainUnit, + this.mainUnit, }); factory BomOutput.fromJson(Map json) { @@ -65,6 +73,10 @@ class BomOutput { outputProductId: (json['output_product_id'] ?? json['outputProductId']) as int, ratio: double.tryParse(json['ratio']?.toString() ?? '0') ?? 0, uom: json['uom'] as String?, + outputProductName: json['output_product_name'] as String?, + outputProductCode: json['output_product_code'] as String?, + ratioMainUnit: json['ratio_main_unit'] != null ? double.tryParse(json['ratio_main_unit'].toString()) : null, + mainUnit: json['main_unit'] as String?, ); } @@ -74,6 +86,10 @@ class BomOutput { 'output_product_id': outputProductId, 'ratio': ratio, 'uom': uom, + if (outputProductName != null) 'output_product_name': outputProductName, + if (outputProductCode != null) 'output_product_code': outputProductCode, + if (ratioMainUnit != null) 'ratio_main_unit': ratioMainUnit, + if (mainUnit != null) 'main_unit': mainUnit, }; } } @@ -219,6 +235,11 @@ class BomExplosionItem { final int? suggestedWarehouseId; final bool isOptional; final String? substituteGroup; + final String? componentProductName; + final String? componentProductCode; + final String? componentProductMainUnit; + final double? requiredQtyMainUnit; + final String? mainUnit; const BomExplosionItem({ required this.componentProductId, @@ -227,6 +248,11 @@ class BomExplosionItem { this.suggestedWarehouseId, this.isOptional = false, this.substituteGroup, + this.componentProductName, + this.componentProductCode, + this.componentProductMainUnit, + this.requiredQtyMainUnit, + this.mainUnit, }); factory BomExplosionItem.fromJson(Map json) { @@ -237,6 +263,11 @@ class BomExplosionItem { suggestedWarehouseId: json['suggested_warehouse_id'] as int?, isOptional: (json['is_optional'] ?? false) as bool, substituteGroup: json['substitute_group'] as String?, + componentProductName: json['component_product_name'] as String?, + componentProductCode: json['component_product_code'] as String?, + componentProductMainUnit: json['component_product_main_unit'] as String?, + requiredQtyMainUnit: json['required_qty_main_unit'] != null ? double.tryParse(json['required_qty_main_unit'].toString()) : null, + mainUnit: json['main_unit'] as String?, ); } } diff --git a/hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart b/hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart new file mode 100644 index 0000000..7d5876c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart @@ -0,0 +1,60 @@ + +/// مدل سطر لیست فاکتورها برای استفاده در DataTableWidget +class InvoiceListItem { + final int id; + final String code; + final String documentType; + final String documentTypeName; + final DateTime documentDate; + final DateTime? registeredAt; + final double? totalAmount; + final String? currencyCode; + final String? createdByName; + final bool isProforma; + final String? description; + + const InvoiceListItem({ + required this.id, + required this.code, + required this.documentType, + required this.documentTypeName, + required this.documentDate, + this.registeredAt, + this.totalAmount, + this.currencyCode, + this.createdByName, + required this.isProforma, + this.description, + }); + + factory InvoiceListItem.fromJson(Map json) { + DateTime _parseDate(dynamic v) { + if (v == null) return DateTime.now(); + if (v is DateTime) return v; + final s = v.toString(); + return DateTime.tryParse(s) ?? DateTime.now(); + } + + double? _toDouble(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()); + } + + return InvoiceListItem( + id: (json['id'] as num?)?.toInt() ?? 0, + code: json['code']?.toString() ?? '', + documentType: json['document_type']?.toString() ?? '', + documentTypeName: json['document_type_name']?.toString() ?? json['document_type']?.toString() ?? '', + documentDate: _parseDate(json['document_date']), + registeredAt: json['registered_at'] != null ? DateTime.tryParse(json['registered_at'].toString()) : null, + totalAmount: _toDouble(json['total_amount']), + currencyCode: json['currency_code']?.toString(), + createdByName: json['created_by_name']?.toString(), + isProforma: json['is_proforma'] == true, + description: json['description']?.toString(), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart index 9f0b97b..a180ad0 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart @@ -78,6 +78,106 @@ class _AccountsPageState extends State { } } + List> _flattenNodes() { + final List> items = >[]; + void dfs(AccountNode n, int level) { + items.add({ + "id": n.id, + "title": ("\u200f" * level) + n.code + " - " + n.name, + }); + for (final c in n.children) { + dfs(c, level + 1); + } + } + for (final r in _roots) { + dfs(r, 0); + } + return items; + } + + Future _openCreateDialog() async { + final t = AppLocalizations.of(context); + final codeCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final typeCtrl = TextEditingController(); + String? selectedParentId; + final parents = _flattenNodes(); + final result = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(t.addAccount), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: codeCtrl, + decoration: InputDecoration(labelText: t.code), + ), + TextField( + controller: nameCtrl, + decoration: InputDecoration(labelText: t.title), + ), + TextField( + controller: typeCtrl, + decoration: InputDecoration(labelText: t.type), + ), + DropdownButtonFormField( + value: selectedParentId, + items: [ + DropdownMenuItem(value: null, child: Text('بدون والد')), + ...parents.map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(), + ], + onChanged: (v) { + selectedParentId = v; + }, + decoration: const InputDecoration(labelText: 'حساب والد'), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)), + FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + final code = codeCtrl.text.trim(); + final atype = typeCtrl.text.trim(); + if (name.isEmpty || code.isEmpty || atype.isEmpty) { + return; + } + final Map payload = { + "name": name, + "code": code, + "account_type": atype, + }; + if (selectedParentId != null && selectedParentId!.isNotEmpty) { + final pid = int.tryParse(selectedParentId!); + if (pid != null) payload["parent_id"] = pid; + } + try { + final api = ApiClient(); + await api.post( + '/api/v1/accounts/business/${widget.businessId}/create', + data: payload, + ); + if (context.mounted) Navigator.of(ctx).pop(true); + } catch (_) { + // نمایش خطا می‌تواند بعداً اضافه شود + } + }, + child: Text(t.add), + ), + ], + ); + }, + ); + if (result == true) { + await _fetch(); + } + } + List<_VisibleNode> _buildVisibleNodes() { final List<_VisibleNode> result = <_VisibleNode>[]; void dfs(AccountNode node, int level) { @@ -225,6 +325,10 @@ class _AccountsPageState extends State { ), ], ), + floatingActionButton: FloatingActionButton( + onPressed: _openCreateDialog, + child: const Icon(Icons.add), + ), ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_info_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_info_settings_page.dart new file mode 100644 index 0000000..654b4bf --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_info_settings_page.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hesabix_ui/models/business_models.dart'; +import 'package:hesabix_ui/services/business_api_service.dart'; +import 'package:hesabix_ui/core/api_client.dart'; + +class BusinessInfoSettingsPage extends StatefulWidget { + final int businessId; + + const BusinessInfoSettingsPage({super.key, required this.businessId}); + + @override + State createState() => _BusinessInfoSettingsPageState(); +} + +class _BusinessInfoSettingsPageState extends State { + final _formKey = GlobalKey(); + + bool _loading = true; + bool _saving = false; + String? _error; + + BusinessResponse? _original; + + final _nameController = TextEditingController(); + final _addressController = TextEditingController(); + final _phoneController = TextEditingController(); + final _mobileController = TextEditingController(); + final _postalCodeController = TextEditingController(); + final _nationalIdController = TextEditingController(); + final _registrationNumberController = TextEditingController(); + final _economicIdController = TextEditingController(); + final _countryController = TextEditingController(); + final _provinceController = TextEditingController(); + final _cityController = TextEditingController(); + + BusinessType? _businessType; + BusinessField? _businessField; + + late final ApiClient _apiClient; + + @override + void initState() { + super.initState(); + _apiClient = ApiClient(); + _loadData(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _phoneController.dispose(); + _mobileController.dispose(); + _postalCodeController.dispose(); + _nationalIdController.dispose(); + _registrationNumberController.dispose(); + _economicIdController.dispose(); + _countryController.dispose(); + _provinceController.dispose(); + _cityController.dispose(); + super.dispose(); + } + + Future _loadData() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final resp = await BusinessApiService.getBusiness(widget.businessId); + _original = resp; + _nameController.text = resp.name; + _addressController.text = resp.address ?? ''; + _phoneController.text = resp.phone ?? ''; + _mobileController.text = resp.mobile ?? ''; + _postalCodeController.text = resp.postalCode ?? ''; + _nationalIdController.text = resp.nationalId ?? ''; + _registrationNumberController.text = resp.registrationNumber ?? ''; + _economicIdController.text = resp.economicId ?? ''; + _countryController.text = resp.country ?? ''; + _provinceController.text = resp.province ?? ''; + _cityController.text = resp.city ?? ''; + _businessType = _resolveBusinessType(resp.businessType); + _businessField = _resolveBusinessField(resp.businessField); + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + BusinessType? _resolveBusinessType(String value) { + for (final t in BusinessType.values) { + if (t.displayName == value) return t; + } + return null; + } + + BusinessField? _resolveBusinessField(String value) { + for (final f in BusinessField.values) { + if (f.displayName == value) return f; + } + return null; + } + + Map _buildUpdatePayload() { + final orig = _original!; + final payload = {}; + + if (_nameController.text.trim() != orig.name) payload['name'] = _nameController.text.trim(); + if (_businessType != null && _businessType!.displayName != orig.businessType) { + payload['business_type'] = _businessType!.displayName; + } + if (_businessField != null && _businessField!.displayName != orig.businessField) { + payload['business_field'] = _businessField!.displayName; + } + final addr = _addressController.text.trim(); + if ((orig.address ?? '') != addr) payload['address'] = addr.isEmpty ? null : addr; + final phone = _phoneController.text.trim(); + if ((orig.phone ?? '') != phone) payload['phone'] = phone.isEmpty ? null : phone; + final mobile = _mobileController.text.trim(); + if ((orig.mobile ?? '') != mobile) payload['mobile'] = mobile.isEmpty ? null : mobile; + final postal = _postalCodeController.text.trim(); + if ((orig.postalCode ?? '') != postal) payload['postal_code'] = postal.isEmpty ? null : postal; + final nid = _nationalIdController.text.trim(); + if ((orig.nationalId ?? '') != nid) payload['national_id'] = nid.isEmpty ? null : nid; + final reg = _registrationNumberController.text.trim(); + if ((orig.registrationNumber ?? '') != reg) payload['registration_number'] = reg.isEmpty ? null : reg; + final eco = _economicIdController.text.trim(); + if ((orig.economicId ?? '') != eco) payload['economic_id'] = eco.isEmpty ? null : eco; + final country = _countryController.text.trim(); + if ((orig.country ?? '') != country) payload['country'] = country.isEmpty ? null : country; + final province = _provinceController.text.trim(); + if ((orig.province ?? '') != province) payload['province'] = province.isEmpty ? null : province; + final city = _cityController.text.trim(); + if ((orig.city ?? '') != city) payload['city'] = city.isEmpty ? null : city; + + return payload; + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + if (_original == null) return; + final payload = _buildUpdatePayload(); + if (payload.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('بدون تغییر'))); + } + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + final resp = await _apiClient.put('/api/v1/businesses/${widget.businessId}', data: payload); + if (resp.data['success'] == true) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('با موفقیت ذخیره شد'))); + context.go('/business/${widget.businessId}/settings'); + } + } else { + throw Exception(resp.data['message'] ?? 'خطا در ذخیره تغییرات'); + } + } catch (e) { + setState(() { + _error = e.toString(); + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_error!))); + } + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return Scaffold( + appBar: AppBar(title: Text(t.businessSettings)), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(t.businessSettings)), + body: Center(child: Text(_error!)), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(t.businessSettings), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.save), + label: Text(t.save), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle(t.generalSettings, cs), + const SizedBox(height: 8), + _buildTextField(controller: _nameController, label: t.businessName, required: true), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildBusinessTypeDropdown(t)), + const SizedBox(width: 12), + Expanded(child: _buildBusinessFieldDropdown(t)), + ], + ), + + const SizedBox(height: 24), + _buildSectionTitle(t.businessContactInfo, cs), + const SizedBox(height: 8), + _buildTextField(controller: _addressController, label: t.address, maxLines: 2), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildTextField(controller: _phoneController, label: t.phone)), + const SizedBox(width: 12), + Expanded(child: _buildTextField(controller: _mobileController, label: t.mobile)), + ], + ), + const SizedBox(height: 12), + _buildTextField(controller: _postalCodeController, label: t.postalCode), + + const SizedBox(height: 24), + _buildSectionTitle(t.businessLegalInfo, cs), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _buildTextField(controller: _nationalIdController, label: t.nationalId)), + const SizedBox(width: 12), + Expanded(child: _buildTextField(controller: _registrationNumberController, label: t.registrationNumber)), + ], + ), + const SizedBox(height: 12), + _buildTextField(controller: _economicIdController, label: t.economicId), + + const SizedBox(height: 24), + _buildSectionTitle(t.businessGeographicInfo, cs), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _buildTextField(controller: _countryController, label: t.country)), + const SizedBox(width: 12), + Expanded(child: _buildTextField(controller: _provinceController, label: t.province)), + ], + ), + const SizedBox(height: 12), + _buildTextField(controller: _cityController, label: t.city), + ], + ), + ), + ), + ); + } + + Widget _buildSectionTitle(String title, ColorScheme cs) { + return Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface)); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + bool required = false, + int maxLines = 1, + }) { + return TextFormField( + controller: controller, + maxLines: maxLines, + decoration: InputDecoration(labelText: label), + validator: (val) { + if (required && (val == null || val.trim().isEmpty)) { + return label; + } + return null; + }, + ); + } + + Widget _buildBusinessTypeDropdown(AppLocalizations t) { + return DropdownButtonFormField( + value: _businessType, + decoration: InputDecoration(labelText: t.businessType), + items: BusinessType.values + .map((e) => DropdownMenuItem(value: e, child: Text(e.displayName))) + .toList(), + onChanged: (val) => setState(() => _businessType = val), + validator: (val) => val == null ? t.businessType : null, + ); + } + + Widget _buildBusinessFieldDropdown(AppLocalizations t) { + return DropdownButtonFormField( + value: _businessField, + decoration: InputDecoration(labelText: t.businessField), + items: BusinessField.values + .map((e) => DropdownMenuItem(value: e, child: Text(e.displayName))) + .toList(), + onChanged: (val) => setState(() => _businessField = val), + validator: (val) => val == null ? t.businessField : null, + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart new file mode 100644 index 0000000..367f9b5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/invoice_list_item.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_config.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; +import 'package:hesabix_ui/widgets/document/document_details_dialog.dart'; + +/// صفحه لیست فاکتورها با ویجت جدول عمومی +class InvoicesListPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + + const InvoicesListPage({ + super.key, + required this.businessId, + required this.calendarController, + required this.authStore, + required this.apiClient, + }); + + @override + State createState() => _InvoicesListPageState(); +} + +class _InvoicesListPageState extends State { + final GlobalKey _tableKey = GlobalKey(); + + String? _selectedInvoiceType; + DateTime? _fromDate; + DateTime? _toDate; + bool? _isProforma; // null=همه، true=پیش فاکتور، false=قطعی + + void _refreshData() { + final state = _tableKey.currentState; + if (state != null) { + try { + // ignore: avoid_dynamic_calls + (state as dynamic).refresh(); + return; + } catch (_) {} + } + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(t), + _buildFilters(t), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: DataTableWidget( + key: _tableKey, + config: _buildTableConfig(t), + fromJson: (json) => InvoiceListItem.fromJson(json), + calendarController: widget.calendarController, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'فاکتورها', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'مدیریت لیست فاکتورها', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // دکمه افزودن فاکتور (در آینده به فرم ایجاد وصل می شود) + FilledButton.icon( + onPressed: _onAddNew, + icon: const Icon(Icons.add), + label: Text(t.add), + ), + ], + ), + ); + } + + Widget _buildFilters(AppLocalizations t) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: SegmentedButton( + segments: const [ + ButtonSegment(value: null, label: Text('همه'), icon: Icon(Icons.all_inclusive)), + ButtonSegment(value: 'invoice_sales', label: Text('فروش'), icon: Icon(Icons.sell_outlined)), + ButtonSegment(value: 'invoice_purchase', label: Text('خرید'), icon: Icon(Icons.shopping_cart_outlined)), + ButtonSegment(value: 'invoice_sales_return', label: Text('برگشت فروش'), icon: Icon(Icons.undo_outlined)), + ButtonSegment(value: 'invoice_purchase_return', label: Text('برگشت خرید'), icon: Icon(Icons.undo)), + ButtonSegment(value: 'invoice_production', label: Text('تولید'), icon: Icon(Icons.factory_outlined)), + ButtonSegment(value: 'invoice_direct_consumption', label: Text('مصرف مستقیم'), icon: Icon(Icons.dining_outlined)), + ButtonSegment(value: 'invoice_waste', label: Text('ضایعات'), icon: Icon(Icons.delete_outline)), + ], + selected: _selectedInvoiceType != null ? {_selectedInvoiceType} : {}, + onSelectionChanged: (set) { + setState(() => _selectedInvoiceType = set.first); + _refreshData(); + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + flex: 3, + child: Row( + children: [ + Expanded( + child: DateInputField( + value: _fromDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _fromDate = date); + _refreshData(); + }, + labelText: 'از تاریخ', + hintText: 'انتخاب تاریخ شروع', + ), + ), + const SizedBox(width: 8), + Expanded( + child: DateInputField( + value: _toDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _toDate = date); + _refreshData(); + }, + labelText: 'تا تاریخ', + hintText: 'انتخاب تاریخ پایان', + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + setState(() { + _fromDate = null; + _toDate = null; + }); + _refreshData(); + }, + icon: const Icon(Icons.clear), + tooltip: 'پاک کردن فیلتر تاریخ', + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: SegmentedButton( + segments: const [ + ButtonSegment(value: null, label: Text('همه')), + ButtonSegment(value: true, label: Text('پیش فاکتور')), + ButtonSegment(value: false, label: Text('قطعی')), + ], + selected: {_isProforma}, + onSelectionChanged: (set) { + setState(() => _isProforma = set.first); + _refreshData(); + }, + ), + ), + ], + ), + ], + ), + ); + } + + DataTableConfig _buildTableConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/invoices/business/${widget.businessId}/search', + title: 'فاکتورها', + excelEndpoint: '/invoices/business/${widget.businessId}/export/excel', + pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf', + columns: [ + // عملیات + ActionColumn( + 'actions', + 'عملیات', + actions: [ + DataTableAction( + icon: Icons.visibility, + label: 'مشاهده', + onTap: (item) => _onView(item as InvoiceListItem), + ), + ], + ), + // کد سند + TextColumn('code', 'کد', formatter: (item) => item.code, width: ColumnWidth.small), + // نوع + TextColumn('document_type', 'نوع', formatter: (item) => item.documentTypeName, width: ColumnWidth.medium), + // تاریخ سند + DateColumn( + 'document_date', + 'تاریخ', + width: ColumnWidth.medium, + formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali), + ), + // مبلغ کل + NumberColumn( + 'total_amount', + 'مبلغ کل', + width: ColumnWidth.large, + formatter: (item) => item.totalAmount != null ? formatWithThousands(item.totalAmount!) : '-', + suffix: ' ریال', + ), + // ارز + TextColumn('currency_code', 'ارز', formatter: (item) => item.currencyCode ?? 'نامشخص', width: ColumnWidth.small), + // ایجادکننده + TextColumn('created_by_name', 'ایجادکننده', formatter: (item) => item.createdByName ?? 'نامشخص', width: ColumnWidth.medium), + // وضعیت + TextColumn('is_proforma', 'وضعیت', formatter: (item) => item.isProforma ? 'پیش فاکتور' : 'قطعی', width: ColumnWidth.small), + ], + searchFields: const ['code', 'description'], + filterFields: const ['document_type'], + dateRangeField: 'document_date', + showSearch: true, + showFilters: true, + showPagination: true, + showColumnSearch: true, + showRefreshButton: true, + showClearFiltersButton: true, + enableRowSelection: false, + enableMultiRowSelection: false, + defaultPageSize: 20, + pageSizeOptions: const [10, 20, 50, 100], + additionalParams: { + 'document_type': _selectedInvoiceType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + if (_isProforma != null) 'is_proforma': _isProforma, + }, + onRowTap: (item) => _onView(item as InvoiceListItem), + emptyStateMessage: 'هیچ فاکتوری یافت نشد', + loadingMessage: 'در حال بارگذاری فاکتورها...', + errorMessage: 'خطا در بارگذاری فاکتورها', + ); + } + + Future _onAddNew() async { + if (!mounted) return; + await context.pushNamed( + 'business_new_invoice', + pathParameters: { + 'business_id': widget.businessId.toString(), + }, + ); + if (!mounted) return; + _refreshData(); + } + + Future _onView(InvoiceListItem item) async { + await showDialog( + context: context, + builder: (_) => DocumentDetailsDialog( + documentId: item.id, + calendarController: widget.calendarController, + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index fb64c94..190f1a7 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -7,6 +7,7 @@ import '../../widgets/invoice/invoice_type_combobox.dart'; import '../../widgets/invoice/code_field_widget.dart'; import '../../widgets/invoice/customer_combobox_widget.dart'; import '../../widgets/invoice/seller_picker_widget.dart'; +import '../../widgets/invoice/person_combobox_widget.dart'; import '../../widgets/invoice/commission_percentage_field.dart'; import '../../widgets/invoice/commission_type_selector.dart'; import '../../widgets/invoice/commission_amount_field.dart'; @@ -49,6 +50,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid final bool _autoGenerateInvoiceNumber = true; Customer? _selectedCustomer; Person? _selectedSeller; + Person? _selectedSupplier; // برای فاکتورهای خرید double? _commissionPercentage; double? _commissionAmount; CommissionType? _commissionType; @@ -219,16 +221,27 @@ class _NewInvoicePageState extends State with SingleTickerProvid InvoiceTypeCombobox( selectedType: _selectedInvoiceType, onTypeChanged: (type) { - setState(() { - _selectedInvoiceType = type; - // به‌روزرسانی TabController اگر تعداد تب‌ها تغییر کرده - final newTabCount = _getTabCountForType(type); - if (newTabCount != _tabController.length) { - _tabController.dispose(); - _tabController = TabController(length: newTabCount, vsync: this); - } - }); - }, + setState(() { + _selectedInvoiceType = type; + // پاک کردن انتخاب‌های قبلی هنگام تغییر نوع فاکتور + if (type == InvoiceType.purchase || type == InvoiceType.purchaseReturn) { + _selectedCustomer = null; + _selectedSeller = null; + } else if (type == InvoiceType.sales || type == InvoiceType.salesReturn) { + _selectedSupplier = null; + } else { + _selectedCustomer = null; + _selectedSupplier = null; + _selectedSeller = null; + } + // به‌روزرسانی TabController اگر تعداد تب‌ها تغییر کرده + final newTabCount = _getTabCountForType(type); + if (newTabCount != _tabController.length) { + _tabController.dispose(); + _tabController = TabController(length: newTabCount, vsync: this); + } + }); + }, isDraft: _isDraft, onDraftChanged: (isDraft) { setState(() { @@ -284,23 +297,41 @@ class _NewInvoicePageState extends State with SingleTickerProvid ), const SizedBox(height: 16), - // مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی می‌شود) - if (!(_selectedInvoiceType == InvoiceType.waste || - _selectedInvoiceType == InvoiceType.directConsumption || - _selectedInvoiceType == InvoiceType.production)) + // مشتری (فقط برای فروش و برگشت از فروش) + if (_selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.salesReturn) CustomerComboboxWidget( - selectedCustomer: _selectedCustomer, - onCustomerChanged: (customer) { - setState(() { - _selectedCustomer = customer; - }); - }, - businessId: widget.businessId, - authStore: widget.authStore, - isRequired: false, - label: 'مشتری', - hintText: 'انتخاب مشتری', - ), + selectedCustomer: _selectedCustomer, + onCustomerChanged: (customer) { + setState(() { + _selectedCustomer = customer; + }); + }, + businessId: widget.businessId, + authStore: widget.authStore, + isRequired: false, + label: 'مشتری', + hintText: 'انتخاب مشتری', + ), + // تامین‌کننده (فقط برای خرید و برگشت از خرید) + if (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.purchaseReturn) ...[ + const SizedBox(height: 16), + PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: _selectedSupplier, + onChanged: (person) { + setState(() { + _selectedSupplier = person; + }); + }, + isRequired: false, + label: 'تامین‌کننده', + hintText: 'انتخاب تامین‌کننده', + personTypes: ['تامین‌کننده', 'فروشنده'], + searchHint: 'جست‌وجو در تامین‌کنندگان...', + ), + ], const SizedBox(height: 16), // ارز فاکتور @@ -459,6 +490,17 @@ class _NewInvoicePageState extends State with SingleTickerProvid onTypeChanged: (type) { setState(() { _selectedInvoiceType = type; + // پاک کردن انتخاب‌های قبلی هنگام تغییر نوع فاکتور + if (type == InvoiceType.purchase || type == InvoiceType.purchaseReturn) { + _selectedCustomer = null; + _selectedSeller = null; + } else if (type == InvoiceType.sales || type == InvoiceType.salesReturn) { + _selectedSupplier = null; + } else { + _selectedCustomer = null; + _selectedSupplier = null; + _selectedSeller = null; + } // به‌روزرسانی TabController اگر تعداد تب‌ها تغییر کرده final newTabCount = _getTabCountForType(type); if (newTabCount != _tabController.length) { @@ -527,19 +569,38 @@ class _NewInvoicePageState extends State with SingleTickerProvid _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production) ? const SizedBox() - : CustomerComboboxWidget( - selectedCustomer: _selectedCustomer, - onCustomerChanged: (customer) { - setState(() { - _selectedCustomer = customer; - }); - }, - businessId: widget.businessId, - authStore: widget.authStore, - isRequired: false, - label: 'مشتری', - hintText: 'انتخاب مشتری', - ), + : (_selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.salesReturn) + ? CustomerComboboxWidget( + selectedCustomer: _selectedCustomer, + onCustomerChanged: (customer) { + setState(() { + _selectedCustomer = customer; + }); + }, + businessId: widget.businessId, + authStore: widget.authStore, + isRequired: false, + label: 'مشتری', + hintText: 'انتخاب مشتری', + ) + : (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.purchaseReturn) + ? PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: _selectedSupplier, + onChanged: (person) { + setState(() { + _selectedSupplier = person; + }); + }, + isRequired: false, + label: 'تامین‌کننده', + hintText: 'انتخاب تامین‌کننده', + personTypes: ['تامین‌کننده', 'فروشنده'], + searchHint: 'جست‌وجو در تامین‌کنندگان...', + ) + : const SizedBox(), ), ], ), @@ -779,11 +840,17 @@ class _NewInvoicePageState extends State with SingleTickerProvid } final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn; - // مشتری برای انواع خاص الزامی نیست - final shouldHaveCustomer = !(_selectedInvoiceType == InvoiceType.waste || _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production); - if (shouldHaveCustomer && _selectedCustomer == null) { + final isPurchaseOrReturn = _selectedInvoiceType == InvoiceType.purchase || _selectedInvoiceType == InvoiceType.purchaseReturn; + + // اعتبارسنجی مشتری برای فروش + if (isSalesOrReturn && _selectedCustomer == null) { return 'انتخاب مشتری الزامی است'; } + + // اعتبارسنجی تامین‌کننده برای خرید + if (isPurchaseOrReturn && _selectedSupplier == null) { + return 'انتخاب تامین‌کننده الزامی است'; + } // اعتبارسنجی کارمزد در حالت فروش if (isSalesOrReturn && _selectedSeller != null && _commissionType != null) { @@ -796,52 +863,97 @@ class _NewInvoicePageState extends State with SingleTickerProvid } } - // ساخت payload - final payload = { - 'type': _selectedInvoiceType!.value, - 'is_draft': _isDraft, - if (_invoiceNumber != null && _invoiceNumber!.trim().isNotEmpty) 'number': _invoiceNumber!.trim(), - 'invoice_date': _invoiceDate!.toIso8601String(), - if (_dueDate != null) 'due_date': _dueDate!.toIso8601String(), - 'currency_id': _selectedCurrencyId, - if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'title': _invoiceTitle, - if (_invoiceReference != null && _invoiceReference!.isNotEmpty) 'reference': _invoiceReference, - if (_selectedCustomer != null) 'customer_id': _selectedCustomer!.id, - if (_selectedSeller?.id != null) 'seller_id': _selectedSeller!.id, - if (_commissionType != null) 'commission_type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount', - if (_commissionType == CommissionType.percentage && _commissionPercentage != null) 'commission_percentage': _commissionPercentage, - if (_commissionType == CommissionType.amount && _commissionAmount != null) 'commission_amount': _commissionAmount, - 'settings': { - 'print_after_save': _printAfterSave, - 'printer': _selectedPrinter, - 'paper_size': _selectedPaperSize, - 'is_official_invoice': _isOfficialInvoice, - 'print_template': _selectedPrintTemplate, - 'send_to_tax_folder': _sendToTaxFolder, - }, - 'transactions': _transactions.map((t) => t.toJson()).toList(), - 'line_items': _lineItems.map((e) => _serializeLineItem(e)).toList(), - 'summary': { - 'subtotal': _sumSubtotal, + // تبدیل نوع فاکتور به فرمت API + String _convertInvoiceTypeToApi(InvoiceType type) { + return 'invoice_${type.value}'; + } + + // ساخت extra_info با person_id و totals + final extraInfo = { + 'totals': { + 'gross': _sumSubtotal, 'discount': _sumDiscount, 'tax': _sumTax, - 'total': _sumTotal, + 'net': _sumTotal, }, }; + + // افزودن person_id بر اساس نوع فاکتور + if (isSalesOrReturn && _selectedCustomer != null) { + extraInfo['person_id'] = _selectedCustomer!.id; + } else if (isPurchaseOrReturn && _selectedSupplier != null) { + extraInfo['person_id'] = _selectedSupplier!.id; + } + + // افزودن اطلاعات فروشنده و کارمزد (اختیاری) + if (isSalesOrReturn && _selectedSeller != null) { + extraInfo['seller_id'] = _selectedSeller!.id; + if (_commissionType != null) { + extraInfo['commission'] = { + 'type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount', + if (_commissionType == CommissionType.percentage && _commissionPercentage != null) + 'value': _commissionPercentage, + if (_commissionType == CommissionType.amount && _commissionAmount != null) + 'value': _commissionAmount, + }; + } + } + + // ساخت payload + final payload = { + 'invoice_type': _convertInvoiceTypeToApi(_selectedInvoiceType!), + 'document_date': _invoiceDate!.toIso8601String().split('T')[0], // فقط تاریخ بدون زمان + 'currency_id': _selectedCurrencyId, + 'is_proforma': _isDraft, + 'extra_info': extraInfo, + if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'description': _invoiceTitle, + 'lines': _lineItems.map((e) => _serializeLineItem(e)).toList(), + }; + + // افزودن payments اگر وجود دارد + if (_transactions.isNotEmpty) { + payload['payments'] = _transactions.map((t) => t.toJson()).toList(); + } + return payload; } Map _serializeLineItem(InvoiceLineItem e) { + // تعیین movement بر اساس نوع فاکتور + String? movement; + if (_selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.purchaseReturn || + _selectedInvoiceType == InvoiceType.directConsumption || + _selectedInvoiceType == InvoiceType.waste) { + movement = 'out'; + } else if (_selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.salesReturn) { + movement = 'in'; + } + // برای production، movement باید در UI تعیین شود (می‌تواند out یا in باشد) + + // محاسبه مقادیر + final lineDiscount = e.discountAmount; + final taxAmount = e.taxAmount; + final lineTotal = e.total; + return { 'product_id': e.productId, - 'unit': e.selectedUnit ?? e.mainUnit, 'quantity': e.quantity, - 'unit_price': e.unitPrice, - 'unit_price_source': e.unitPriceSource, - 'discount_type': e.discountType, - 'discount_value': e.discountValue, - 'tax_rate': e.taxRate, if ((e.description ?? '').isNotEmpty) 'description': e.description, + 'extra_info': { + 'unit_price': e.unitPrice, + 'line_discount': lineDiscount, + 'tax_amount': taxAmount, + 'line_total': lineTotal, + if (movement != null) 'movement': movement, + // اطلاعات اضافی برای ردیابی + 'unit': e.selectedUnit ?? e.mainUnit, + 'unit_price_source': e.unitPriceSource, + 'discount_type': e.discountType, + 'discount_value': e.discountValue, + 'tax_rate': e.taxRate, + }, }; } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart index 71b2d96..ae3fa88 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart @@ -54,7 +54,7 @@ class _SettingsPageState extends State { title: t.businessSettings, subtitle: t.businessSettingsDescription, icon: Icons.business, - onTap: () => _showBusinessSettingsDialog(context), + onTap: () => context.go('/business/${widget.businessId}/settings/business'), ), _buildSettingItem( context, @@ -215,32 +215,7 @@ class _SettingsPageState extends State { } // دیالوگ‌های تنظیمات - void _showBusinessSettingsDialog(BuildContext context) { - final t = AppLocalizations.of(context); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.businessSettings), - content: Text(t.businessSettingsDialogContent), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(t.close), - ), - FilledButton( - onPressed: () { - Navigator.pop(context); - // Navigate to business dashboard for now (until business settings page is created) - context.go('/business/${widget.businessId}/dashboard'); - }, - child: Text(t.edit), - ), - ], - ), - ); - } - - + void _showPrintDocumentsDialog(BuildContext context) { final t = AppLocalizations.of(context); showDialog( diff --git a/hesabixUI/hesabix_ui/lib/services/account_service.dart b/hesabixUI/hesabix_ui/lib/services/account_service.dart index 1c84346..b1f761d 100644 --- a/hesabixUI/hesabix_ui/lib/services/account_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/account_service.dart @@ -1,5 +1,4 @@ import '../core/api_client.dart'; -import '../models/account_model.dart'; class AccountService { final ApiClient _client; @@ -88,3 +87,4 @@ class AccountService { } } } + diff --git a/hesabixUI/hesabix_ui/lib/services/bom_service.dart b/hesabixUI/hesabix_ui/lib/services/bom_service.dart index e388ec4..cec76b1 100644 --- a/hesabixUI/hesabix_ui/lib/services/bom_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/bom_service.dart @@ -48,6 +48,19 @@ class BomService { final data = (res.data?['data'] as Map? ?? {}); return BomExplosionResult.fromJson(data); } + + Future> produceDraft({required int businessId, int? productId, int? bomId, required double quantity, int? currencyId, int? fiscalYearId, String? documentDate}) async { + final payload = { + if (productId != null) 'product_id': productId, + if (bomId != null) 'bom_id': bomId, + 'quantity': quantity, + if (currencyId != null) 'currency_id': currencyId, + if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId, + if (documentDate != null) 'document_date': documentDate, + }; + final res = await _api.post>('/api/v1/boms/business/$businessId/produce_draft', data: payload); + return (res.data?['data'] as Map? ?? {}); + } } diff --git a/hesabixUI/hesabix_ui/lib/services/production_settings_service.dart b/hesabixUI/hesabix_ui/lib/services/production_settings_service.dart new file mode 100644 index 0000000..839e59c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/production_settings_service.dart @@ -0,0 +1,29 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ProductionSettingsService { + static String _keyInventoryCode(int businessId) => 'prod_inv_code_$businessId'; + static String _keyWipCode(int businessId) => 'prod_wip_code_$businessId'; + + Future<(String? inventoryCode, String? wipCode)> getDefaultAccounts(int businessId) async { + final prefs = await SharedPreferences.getInstance(); + final inv = prefs.getString(_keyInventoryCode(businessId)); + final wip = prefs.getString(_keyWipCode(businessId)); + return (inv, wip); + } + + Future saveDefaultAccounts({required int businessId, String? inventoryCode, String? wipCode}) async { + final prefs = await SharedPreferences.getInstance(); + if (inventoryCode == null || inventoryCode.isEmpty) { + await prefs.remove(_keyInventoryCode(businessId)); + } else { + await prefs.setString(_keyInventoryCode(businessId), inventoryCode); + } + if (wipCode == null || wipCode.isEmpty) { + await prefs.remove(_keyWipCode(businessId)); + } else { + await prefs.setString(_keyWipCode(businessId), wipCode); + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart index c1c86b4..ced4622 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart @@ -8,6 +8,7 @@ import 'package:hesabix_ui/services/document_service.dart'; import 'package:hesabix_ui/services/account_service.dart'; import 'package:hesabix_ui/widgets/date_input_field.dart'; import 'package:hesabix_ui/widgets/document/document_line_editor.dart'; +import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart'; /// دیالوگ ایجاد یا ویرایش سند حسابداری دستی class DocumentFormDialog extends StatefulWidget { @@ -18,6 +19,9 @@ class DocumentFormDialog extends StatefulWidget { final DocumentModel? document; // null = ایجاد جدید, not null = ویرایش final int? fiscalYearId; final int? currencyId; + final List? initialLines; // خطوط اولیه (مثلاً پیش نویس تولید) + final String? initialDescription; + final DateTime? initialDocumentDate; const DocumentFormDialog({ super.key, @@ -28,6 +32,9 @@ class DocumentFormDialog extends StatefulWidget { this.document, this.fiscalYearId, this.currencyId, + this.initialLines, + this.initialDescription, + this.initialDocumentDate, }); @override @@ -56,18 +63,26 @@ class _DocumentFormDialogState extends State { void initState() { super.initState(); _service = DocumentService(widget.apiClient); - _currencyId = widget.currencyId ?? 1; // پیش‌فرض ریال - _documentDate = DateTime.now(); + _currencyId = widget.currencyId; // اگر null باشد، CurrencyPickerWidget ارز پیش‌فرض را از API انتخاب می‌کند + _documentDate = widget.initialDocumentDate ?? DateTime.now(); // اگر حالت ویرایش است، مقادیر را بارگذاری کن if (widget.document != null) { _loadDocumentData(); } else { - // خط خالی برای شروع - _lines = [ - DocumentLineEdit(), - DocumentLineEdit(), - ]; + // اگر خطوط اولیه ارسال شده باشد (مثل پیش نویس تولید) از آن استفاده کن + if (widget.initialLines != null && widget.initialLines!.isNotEmpty) { + _lines = widget.initialLines!.map((e) => e.copy()).toList(); + if (widget.initialDescription != null && widget.initialDescription!.isNotEmpty) { + _descriptionController.text = widget.initialDescription!; + } + } else { + // خط خالی برای شروع + _lines = [ + DocumentLineEdit(), + DocumentLineEdit(), + ]; + } } } @@ -181,6 +196,14 @@ class _DocumentFormDialogState extends State { return; } + // بررسی ارز انتخابی + if (_currencyId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('انتخاب ارز الزامی است')), + ); + return; + } + // بررسی حداقل 2 سطر if (_lines.length < 2) { ScaffoldMessenger.of(context).showSnackBar( @@ -418,25 +441,19 @@ class _DocumentFormDialogState extends State { ), const SizedBox(width: 16), - // ارز (ساده شده - در آینده از API بیاید) + // ارز (لیست ارزهای کسب‌وکار با انتخاب خودکار ارز پیش‌فرض) Expanded( flex: 2, - child: DropdownButtonFormField( - value: _currencyId, - decoration: const InputDecoration( - labelText: 'ارز', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 1, child: Text('ریال')), - DropdownMenuItem(value: 2, child: Text('دلار')), - DropdownMenuItem(value: 3, child: Text('یورو')), - ], + child: CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, onChanged: (value) { setState(() { _currencyId = value; }); }, + label: 'ارز', + hintText: 'انتخاب ارز', ), ), const SizedBox(width: 16), diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart new file mode 100644 index 0000000..67a6d4a --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart @@ -0,0 +1,487 @@ +import 'package:flutter/material.dart'; + +import '../../models/bom_models.dart'; +import '../../services/bom_service.dart'; +import '../invoice/product_combobox_widget.dart'; + +class BomEditorDialog extends StatefulWidget { + final int businessId; + final ProductBOM bom; + + const BomEditorDialog({super.key, required this.businessId, required this.bom}); + + @override + State createState() => _BomEditorDialogState(); +} + +class _BomEditorDialogState extends State with SingleTickerProviderStateMixin { + late final BomService _service; + + // Header fields + late TextEditingController _nameController; + late TextEditingController _versionController; + bool _isDefault = false; + final TextEditingController _yieldController = TextEditingController(); + final TextEditingController _wastageController = TextEditingController(); + + // Lines + late List _items; + late List _outputs; + late List _operations; + late List?> _itemSelectedProducts; + late List?> _outputSelectedProducts; + + bool _saving = false; + late TabController _tabController; + + @override + void initState() { + super.initState(); + _service = BomService(); + _nameController = TextEditingController(text: widget.bom.name); + _versionController = TextEditingController(text: widget.bom.version); + _isDefault = widget.bom.isDefault; + _yieldController.text = widget.bom.yieldPercent?.toString() ?? ''; + _wastageController.text = widget.bom.wastagePercent?.toString() ?? ''; + + _items = List.from(widget.bom.items); + _outputs = List.from(widget.bom.outputs); + _operations = List.from(widget.bom.operations); + _itemSelectedProducts = List?>.filled(_items.length, null); + _outputSelectedProducts = List?>.filled(_outputs.length, null); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _nameController.dispose(); + _versionController.dispose(); + _yieldController.dispose(); + _wastageController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: SizedBox( + width: 900, + height: 700, + child: Column( + children: [ + _buildHeader(context), + const Divider(height: 1), + _buildTabs(), + Expanded(child: _buildTabViews()), + const Divider(height: 1), + _buildFooter(context), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('ویرایش فرمول تولید', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'عنوان', border: OutlineInputBorder()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _versionController, + decoration: const InputDecoration(labelText: 'نسخه', border: OutlineInputBorder()), + ), + ), + const SizedBox(width: 12), + CheckboxListTile( + value: _isDefault, + onChanged: (v) => setState(() => _isDefault = v ?? false), + title: const Text('پیش‌فرض'), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _yieldController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'بازده کل (%)', border: OutlineInputBorder()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _wastageController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'پرت کل (%)', border: OutlineInputBorder()), + ), + ), + ], + ) + ], + ), + ); + } + + Widget _buildTabs() { + return TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'مواد اولیه'), + Tab(text: 'خروجی‌ها'), + Tab(text: 'عملیات'), + ], + ); + } + + Widget _buildTabViews() { + return TabBarView( + controller: _tabController, + children: [ + _buildItemsEditor(), + _buildOutputsEditor(), + _buildOperationsEditor(), + ], + ); + } + + Widget _buildItemsEditor() { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: () { + setState(() { + _items = [ + ..._items, + BomItem(lineNo: (_items.isEmpty ? 1 : (_items.last.lineNo + 1)), componentProductId: 0, qtyPer: 1), + ]; + _itemSelectedProducts = [..._itemSelectedProducts, null]; + }); + }, + icon: const Icon(Icons.add), + label: const Text('افزودن سطر مواد'), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final it = _items[i]; + final lineCtrl = TextEditingController(text: it.lineNo.toString()); + final qtyCtrl = TextEditingController(text: it.qtyPer.toString()); + final uomCtrl = TextEditingController(text: it.uom ?? ''); + final wastCtrl = TextEditingController(text: it.wastagePercent?.toString() ?? ''); + final substCtrl = TextEditingController(text: it.substituteGroup ?? ''); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + _num(lineCtrl, 'ردیف', (v) => _updateItem(i, lineNo: int.tryParse(v))), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: ProductComboboxWidget( + businessId: widget.businessId, + selectedProduct: _itemSelectedProducts[i], + label: 'کالا', + hintText: 'جست‌وجوی کالا', + onChanged: (p) { + setState(() { + _itemSelectedProducts[i] = p; + final pid = p == null ? null : (p['id'] as int?); + if (pid != null) { + _updateItem(i, componentProductId: pid); + } + }); + }, + ), + ), + const SizedBox(width: 8), + _num(qtyCtrl, 'مقدار برای 1 واحد', (v) => _updateItem(i, qtyPer: double.tryParse(v))), + const SizedBox(width: 8), + _text(uomCtrl, 'واحد', (v) => _updateItem(i, uom: v.isEmpty ? null : v)), + const SizedBox(width: 8), + _num(wastCtrl, 'پرت (%)', (v) => _updateItem(i, wastagePercent: double.tryParse(v))), + const SizedBox(width: 8), + _text(substCtrl, 'گروه جایگزین', (v) => _updateItem(i, substituteGroup: v.isEmpty ? null : v)), + IconButton(onPressed: () => setState(() { _items.removeAt(i); _itemSelectedProducts.removeAt(i); }), icon: const Icon(Icons.delete_outline)), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildOutputsEditor() { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: () { + setState(() { + _outputs = [ + ..._outputs, + BomOutput(lineNo: (_outputs.isEmpty ? 1 : (_outputs.last.lineNo + 1)), outputProductId: 0, ratio: 1), + ]; + _outputSelectedProducts = [..._outputSelectedProducts, null]; + }); + }, + icon: const Icon(Icons.add), + label: const Text('افزودن سطر خروجی'), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.separated( + itemCount: _outputs.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final ot = _outputs[i]; + final lineCtrl = TextEditingController(text: ot.lineNo.toString()); + final ratioCtrl = TextEditingController(text: ot.ratio.toString()); + final uomCtrl = TextEditingController(text: ot.uom ?? ''); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + _num(lineCtrl, 'ردیف', (v) => _updateOutput(i, lineNo: int.tryParse(v))), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: ProductComboboxWidget( + businessId: widget.businessId, + selectedProduct: _outputSelectedProducts[i], + label: 'محصول خروجی', + hintText: 'جست‌وجوی محصول خروجی', + onChanged: (p) { + setState(() { + _outputSelectedProducts[i] = p; + final pid = p == null ? null : (p['id'] as int?); + if (pid != null) { + _updateOutput(i, outputProductId: pid); + } + }); + }, + ), + ), + const SizedBox(width: 8), + _num(ratioCtrl, 'نسبت', (v) => _updateOutput(i, ratio: double.tryParse(v))), + const SizedBox(width: 8), + _text(uomCtrl, 'واحد', (v) => _updateOutput(i, uom: v.isEmpty ? null : v)), + IconButton(onPressed: () => setState(() { _outputs.removeAt(i); _outputSelectedProducts.removeAt(i); }), icon: const Icon(Icons.delete_outline)), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildOperationsEditor() { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: () { + setState(() { + _operations = [ + ..._operations, + const BomOperation(lineNo: 1, operationName: ''), + ]; + }); + }, + icon: const Icon(Icons.add), + label: const Text('افزودن عملیات'), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.separated( + itemCount: _operations.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final op = _operations[i]; + final lineCtrl = TextEditingController(text: op.lineNo.toString()); + final nameCtrl = TextEditingController(text: op.operationName); + final fixedCtrl = TextEditingController(text: op.costFixed?.toString() ?? ''); + final perCtrl = TextEditingController(text: op.costPerUnit?.toString() ?? ''); + final uomCtrl = TextEditingController(text: op.costUom ?? ''); + final wcCtrl = TextEditingController(text: op.workCenter ?? ''); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + _num(lineCtrl, 'ردیف', (v) => _updateOperation(i, lineNo: int.tryParse(v))), + const SizedBox(width: 8), + _text(nameCtrl, 'نام عملیات', (v) => _updateOperation(i, operationName: v)), + const SizedBox(width: 8), + _num(fixedCtrl, 'هزینه ثابت', (v) => _updateOperation(i, costFixed: double.tryParse(v))), + const SizedBox(width: 8), + _num(perCtrl, 'هزینه واحد', (v) => _updateOperation(i, costPerUnit: double.tryParse(v))), + const SizedBox(width: 8), + _text(uomCtrl, 'واحد هزینه', (v) => _updateOperation(i, costUom: v.isEmpty ? null : v)), + const SizedBox(width: 8), + _text(wcCtrl, 'ایستگاه کاری', (v) => _updateOperation(i, workCenter: v.isEmpty ? null : v)), + IconButton(onPressed: () => setState(() => _operations.removeAt(i)), icon: const Icon(Icons.delete_outline)), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildFooter(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('انصراف'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.save), + label: const Text('ذخیره'), + ), + ], + ), + ); + } + + Widget _num(TextEditingController c, String label, void Function(String) onChanged) { + return Expanded( + child: TextField( + controller: c, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), + onChanged: onChanged, + ), + ); + } + + Widget _text(TextEditingController c, String label, void Function(String) onChanged) { + return Expanded( + child: TextField( + controller: c, + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), + onChanged: onChanged, + ), + ); + } + + void _updateItem(int index, {int? lineNo, int? componentProductId, double? qtyPer, String? uom, double? wastagePercent, String? substituteGroup}) { + final current = _items[index]; + setState(() { + _items[index] = BomItem( + lineNo: lineNo ?? current.lineNo, + componentProductId: componentProductId ?? current.componentProductId, + qtyPer: qtyPer ?? current.qtyPer, + uom: uom ?? current.uom, + wastagePercent: wastagePercent ?? current.wastagePercent, + isOptional: current.isOptional, + substituteGroup: substituteGroup ?? current.substituteGroup, + suggestedWarehouseId: current.suggestedWarehouseId, + ); + }); + } + + void _updateOutput(int index, {int? lineNo, int? outputProductId, double? ratio, String? uom}) { + final current = _outputs[index]; + setState(() { + _outputs[index] = BomOutput( + lineNo: lineNo ?? current.lineNo, + outputProductId: outputProductId ?? current.outputProductId, + ratio: ratio ?? current.ratio, + uom: uom ?? current.uom, + outputProductName: current.outputProductName, + outputProductCode: current.outputProductCode, + ); + }); + } + + void _updateOperation(int index, {int? lineNo, String? operationName, double? costFixed, double? costPerUnit, String? costUom, String? workCenter}) { + final current = _operations[index]; + setState(() { + _operations[index] = BomOperation( + lineNo: lineNo ?? current.lineNo, + operationName: operationName ?? current.operationName, + costFixed: costFixed ?? current.costFixed, + costPerUnit: costPerUnit ?? current.costPerUnit, + costUom: costUom ?? current.costUom, + workCenter: workCenter ?? current.workCenter, + ); + }); + } + + Future _save() async { + setState(() => _saving = true); + try { + final payload = { + 'version': _versionController.text.trim(), + 'name': _nameController.text.trim(), + 'is_default': _isDefault, + 'yield_percent': _yieldController.text.trim().isEmpty ? null : double.tryParse(_yieldController.text.replaceAll(',', '.')), + 'wastage_percent': _wastageController.text.trim().isEmpty ? null : double.tryParse(_wastageController.text.replaceAll(',', '.')), + 'items': _items.map((e) => e.toJson()).toList(), + 'outputs': _outputs.map((e) => e.toJson()).toList(), + 'operations': _operations.map((e) => e.toJson()).toList(), + }; + final updated = await _service.update( + businessId: widget.businessId, + bomId: widget.bom.id!, + payload: payload, + ); + if (!mounted) return; + Navigator.of(context).pop(updated); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ذخیره: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/production_settings_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/production_settings_dialog.dart new file mode 100644 index 0000000..0ea946f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/production_settings_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../../services/production_settings_service.dart'; + +class ProductionSettingsDialog extends StatefulWidget { + final int businessId; + const ProductionSettingsDialog({super.key, required this.businessId}); + + @override + State createState() => _ProductionSettingsDialogState(); +} + +class _ProductionSettingsDialogState extends State { + final _service = ProductionSettingsService(); + final _invCtrl = TextEditingController(); + final _wipCtrl = TextEditingController(); + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final (inv, wip) = await _service.getDefaultAccounts(widget.businessId); + if (!mounted) return; + setState(() { + _invCtrl.text = inv ?? '10102'; + _wipCtrl.text = wip ?? '10106'; + _loading = false; + }); + } + + @override + void dispose() { + _invCtrl.dispose(); + _wipCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('تنظیمات حساب‌های تولید', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + if (_loading) const Center(child: CircularProgressIndicator()) else ...[ + TextField( + controller: _invCtrl, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'کد حساب موجودی کالا (مصرف مواد)' + ), + ), + const SizedBox(height: 10), + TextField( + controller: _wipCtrl, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'کد حساب کالای در جریان ساخت/محصول تولیدی' + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('انصراف')), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + await _service.saveDefaultAccounts( + businessId: widget.businessId, + inventoryCode: _invCtrl.text.trim(), + wipCode: _wipCtrl.text.trim(), + ); + if (!mounted) return; + Navigator.pop(context, true); + }, + child: const Text('ذخیره'), + ), + ], + ) + ], + ], + ), + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart index 03014f5..bb03473 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart @@ -1,6 +1,16 @@ import 'package:flutter/material.dart'; import '../../../services/bom_service.dart'; import '../../../models/bom_models.dart'; +import '../../product/bom_editor_dialog.dart'; +import '../../../core/api_client.dart'; +import '../../../core/auth_store.dart'; +import '../../../core/calendar_controller.dart'; +import '../../document/document_form_dialog.dart'; +import '../../document/document_line_editor.dart'; +import '../../../services/account_service.dart'; +import '../../../models/account_model.dart'; +import '../production_settings_dialog.dart'; +import '../../../services/production_settings_service.dart'; class ProductBomSection extends StatefulWidget { final int businessId; @@ -69,6 +79,18 @@ class _ProductBomSectionState extends State { children: [ Text('فرمول‌های تولید', style: Theme.of(context).textTheme.titleMedium), const Spacer(), + Tooltip( + message: 'تنظیمات تولید', + child: IconButton( + onPressed: () async { + await showDialog( + context: context, + builder: (_) => ProductionSettingsDialog(businessId: widget.businessId), + ); + }, + icon: const Icon(Icons.settings_suggest_outlined), + ), + ), FilledButton.icon( onPressed: _showCreateDialog, icon: const Icon(Icons.add), @@ -95,6 +117,11 @@ class _ProductBomSectionState extends State { icon: const Icon(Icons.auto_awesome), onPressed: () => _explode(bom), ), + IconButton( + tooltip: 'ویرایش جزئیات', + icon: const Icon(Icons.tune), + onPressed: () => _openEditor(bom), + ), IconButton( tooltip: 'ویرایش', icon: const Icon(Icons.edit), @@ -127,16 +154,37 @@ class _ProductBomSectionState extends State { Future _explode(ProductBOM bom) async { try { + // دریافت مقدار تولید از کاربر + final qtyController = TextEditingController(text: '1'); + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('انفجار فرمول') + , + content: TextField( + controller: qtyController, + decoration: const InputDecoration(labelText: 'مقدار تولید', hintText: 'مثلاً 10'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ادامه')), + ], + ), + ); + if (ok != true) return; + final qty = double.tryParse(qtyController.text.replaceAll(',', '.')) ?? 1; + final result = await _service.explode( businessId: widget.businessId, bomId: bom.id, - quantity: 1, + quantity: qty, ); if (!mounted) return; await showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('خروجی انفجار فرمول (برای ۱ واحد)'), + title: Text('خروجی انفجار فرمول (برای ${qty.toString()} واحد)'), content: SizedBox( width: 500, child: Column( @@ -151,7 +199,29 @@ class _ProductBomSectionState extends State { itemCount: result.items.length, itemBuilder: (ctx, i) { final it = result.items[i]; - return Text('- ${it.componentProductId} × ${it.requiredQty} ${it.uom ?? ''}'); + final name = it.componentProductName ?? '#${it.componentProductId}'; + final unit = it.uom ?? it.componentProductMainUnit ?? ''; + final mainUnit = it.mainUnit ?? it.componentProductMainUnit ?? ''; + final showConv = it.requiredQtyMainUnit != null && (unit != mainUnit) && mainUnit.isNotEmpty; + final convText = showConv ? ' (≈ ${it.requiredQtyMainUnit} $mainUnit)' : ''; + return Text('- $name × ${it.requiredQty} ${unit.isEmpty ? '' : unit}$convText'); + }, + ), + ), + const SizedBox(height: 16), + const Text('خروجی‌ها:'), + const SizedBox(height: 8), + SizedBox( + height: 120, + child: ListView.builder( + itemCount: result.outputs.length, + itemBuilder: (ctx, i) { + final ot = result.outputs[i]; + final name = ot.outputProductName ?? '#${ot.outputProductId}'; + final mainUnit = ot.mainUnit; + final showConv = ot.ratioMainUnit != null && mainUnit != null && (ot.uom ?? '') != mainUnit; + final convText = showConv ? ' (≈ ${ot.ratioMainUnit} $mainUnit)' : ''; + return Text('- $name: ${ot.ratio} ${ot.uom ?? ''}$convText'); }, ), ), @@ -160,6 +230,87 @@ class _ProductBomSectionState extends State { ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('بستن')), + FilledButton.icon( + onPressed: () async { + try { + final draft = await _service.produceDraft( + businessId: widget.businessId, + bomId: bom.id, + quantity: qty, + ); + if (!mounted) return; + Navigator.of(context).pop(); + // یافتن حساب‌های پیش‌فرض + final accountService = AccountService(client: ApiClient()); + final prodSettings = ProductionSettingsService(); + final (savedInvCode, savedWipCode) = await prodSettings.getDefaultAccounts(widget.businessId); + Future _getAccountByCode(String code) async { + try { + final res = await accountService.searchAccounts(businessId: widget.businessId, searchQuery: code, limit: 10); + final items = (res['items'] as List? ?? const []) + .map((e) => Account.fromJson(Map.from(e as Map))) + .toList(); + // جستجوی دقیق بر اساس کد + final exact = items.where((a) => a.code == code).toList(); + if (exact.isNotEmpty) return exact.first; + return items.isNotEmpty ? items.first : null; + } catch (_) { + return null; + } + } + + final inventoryAccount = await _getAccountByCode((savedInvCode ?? '10102')); + final wipAccount = await _getAccountByCode((savedWipCode ?? '10106')); + + // ساخت خطوط اولیه از پیش‌نویس برای فرم سند + final lines = []; + final draftLines = (draft['lines'] as List?) ?? const []; + for (final raw in draftLines) { + final m = Map.from(raw as Map); + final isConsumption = (m['description']?.toString() ?? '').contains('مصرف'); + final Account? defaultAccount = isConsumption + ? inventoryAccount + : (wipAccount ?? inventoryAccount); + lines.add( + DocumentLineEdit( + account: defaultAccount, + detail: { + if (m['product_id'] != null) 'product_id': m['product_id'], + }, + quantity: m['quantity'] is num ? (m['quantity'] as num).toDouble() : double.tryParse(m['quantity']?.toString() ?? ''), + debit: 0, + credit: 0, + description: m['description']?.toString(), + ), + ); + } + + // بارگذاری کنترلر تقویم (در صورت عدم وجود) + final calendarController = await CalendarController.load(); + + // باز کردن فرم سند با مقداردهی اولیه + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DocumentFormDialog( + businessId: widget.businessId, + calendarController: calendarController, + authStore: AuthStore(), + apiClient: ApiClient(), + fiscalYearId: null, + currencyId: null, + initialLines: lines, + initialDescription: draft['description']?.toString(), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ایجاد پیش‌نویس: $e'))); + } + }, + icon: const Icon(Icons.playlist_add), + label: const Text('ایجاد پیش‌نویس سند تولید'), + ), ], ), ); @@ -274,6 +425,18 @@ class _ProductBomSectionState extends State { } } + Future _openEditor(ProductBOM bom) async { + final updated = await showDialog( + context: context, + builder: (_) => BomEditorDialog(businessId: widget.businessId, bom: bom), + ); + if (updated != null && mounted) { + setState(() { + _items = _items.map((e) => e.id == updated.id ? updated : e).toList(); + }); + } + } + Future _delete(ProductBOM bom) async { final ok = await showDialog( context: context,