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}
+
+
+
+
+
+
+ {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