progress in invoices

This commit is contained in:
Hesabix 2025-10-31 18:02:23 +00:00
parent 56ba9a74e8
commit 9701fa31b2
29 changed files with 4654 additions and 237 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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,

View file

@ -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'<th>{escape(header)}</th>' 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'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
html_content = f"""
<!DOCTYPE html>
<html dir='{ 'rtl' if is_fa else 'ltr' }'>
<head>
<meta charset='utf-8'>
<title>{title_text}</title>
<style>
@page {{ margin: 1cm; size: A4; }}
body {{ font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'}; font-size: 12px; line-height: 1.4; color: #333; direction: {'rtl' if is_fa else 'ltr'}; }}
.header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #366092; }}
.title {{ font-size: 18px; font-weight: bold; color: #366092; }}
.meta {{ font-size: 11px; color: #666; }}
.table-wrapper {{ overflow-x: auto; margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; font-size: 11px; }}
thead {{ background-color: #366092; color: #fff; }}
th {{ border: 1px solid #d7dde6; padding: 8px 6px; text-align: {'right' if is_fa else 'left'}; font-weight: bold; white-space: nowrap; }}
tbody tr:nth-child(even) {{ background-color: #f8f9fa; }}
tbody tr:hover {{ background-color: #e9ecef; }}
tbody td {{ border: 1px solid #d7dde6; padding: 5px 4px; vertical-align: top; overflow-wrap: anywhere; word-break: break-word; white-space: normal; text-align: {'right' if is_fa else 'left'}; }}
.footer {{ position: running(footer); font-size: 10px; color: #666; margin-top: 8px; text-align: {'left' if is_fa else 'right'}; }}
</style>
</head>
<body>
<div class='header'>
<div>
<div class='title'>{title_text}</div>
<div class='meta'>{label_biz}: {escape(business_name)}</div>
</div>
<div class='meta'>{label_date}: {escape(now)}</div>
</div>
<div class='table-wrapper'>
<table>
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
</div>
<div class='footer'>{footer_text}</div>
</body>
</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",
},
)

View file

@ -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)

View file

@ -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

View file

@ -59,6 +59,8 @@ class BaseRepository(Generic[T]):
self.db.delete(obj)
self.db.commit()
def update(self, obj: T) -> None:
"""بروزرسانی رکورد در دیتابیس"""
self.db.commit()
def update(self, obj: T) -> T:
"""بروزرسانی رکورد در دیتابیس و برگرداندن شیء تازه‌سازی شده"""
self.db.commit()
self.db.refresh(obj)
return obj

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)},
}

File diff suppressed because it is too large Load diff

View file

@ -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 = <String, dynamic>{
'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)

View file

@ -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:

View file

@ -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<MyApp> {
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<MyApp> {
);
},
),
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',

View file

@ -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<String, dynamic> 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<String, dynamic> 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?,
);
}
}

View file

@ -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<String, dynamic> 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(),
);
}
}

View file

@ -78,6 +78,106 @@ class _AccountsPageState extends State<AccountsPage> {
}
}
List<Map<String, String>> _flattenNodes() {
final List<Map<String, String>> items = <Map<String, String>>[];
void dfs(AccountNode n, int level) {
items.add({
"id": n.id,
"title": ("\u200f" * level) + n.code + " - " + n.name,
});
for (final c in n.children) {
dfs(c, level + 1);
}
}
for (final r in _roots) {
dfs(r, 0);
}
return items;
}
Future<void> _openCreateDialog() async {
final t = AppLocalizations.of(context);
final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final typeCtrl = TextEditingController();
String? selectedParentId;
final parents = _flattenNodes();
final result = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(t.addAccount),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: codeCtrl,
decoration: InputDecoration(labelText: t.code),
),
TextField(
controller: nameCtrl,
decoration: InputDecoration(labelText: t.title),
),
TextField(
controller: typeCtrl,
decoration: InputDecoration(labelText: t.type),
),
DropdownButtonFormField<String>(
value: selectedParentId,
items: [
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
],
onChanged: (v) {
selectedParentId = v;
},
decoration: const InputDecoration(labelText: 'حساب والد'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton(
onPressed: () async {
final name = nameCtrl.text.trim();
final code = codeCtrl.text.trim();
final atype = typeCtrl.text.trim();
if (name.isEmpty || code.isEmpty || atype.isEmpty) {
return;
}
final Map<String, dynamic> payload = {
"name": name,
"code": code,
"account_type": atype,
};
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
final pid = int.tryParse(selectedParentId!);
if (pid != null) payload["parent_id"] = pid;
}
try {
final api = ApiClient();
await api.post(
'/api/v1/accounts/business/${widget.businessId}/create',
data: payload,
);
if (context.mounted) Navigator.of(ctx).pop(true);
} catch (_) {
// نمایش خطا میتواند بعداً اضافه شود
}
},
child: Text(t.add),
),
],
);
},
);
if (result == true) {
await _fetch();
}
}
List<_VisibleNode> _buildVisibleNodes() {
final List<_VisibleNode> result = <_VisibleNode>[];
void dfs(AccountNode node, int level) {
@ -225,6 +325,10 @@ class _AccountsPageState extends State<AccountsPage> {
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openCreateDialog,
child: const Icon(Icons.add),
),
);
}
}

View file

@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/models/business_models.dart';
import 'package:hesabix_ui/services/business_api_service.dart';
import 'package:hesabix_ui/core/api_client.dart';
class BusinessInfoSettingsPage extends StatefulWidget {
final int businessId;
const BusinessInfoSettingsPage({super.key, required this.businessId});
@override
State<BusinessInfoSettingsPage> createState() => _BusinessInfoSettingsPageState();
}
class _BusinessInfoSettingsPageState extends State<BusinessInfoSettingsPage> {
final _formKey = GlobalKey<FormState>();
bool _loading = true;
bool _saving = false;
String? _error;
BusinessResponse? _original;
final _nameController = TextEditingController();
final _addressController = TextEditingController();
final _phoneController = TextEditingController();
final _mobileController = TextEditingController();
final _postalCodeController = TextEditingController();
final _nationalIdController = TextEditingController();
final _registrationNumberController = TextEditingController();
final _economicIdController = TextEditingController();
final _countryController = TextEditingController();
final _provinceController = TextEditingController();
final _cityController = TextEditingController();
BusinessType? _businessType;
BusinessField? _businessField;
late final ApiClient _apiClient;
@override
void initState() {
super.initState();
_apiClient = ApiClient();
_loadData();
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_postalCodeController.dispose();
_nationalIdController.dispose();
_registrationNumberController.dispose();
_economicIdController.dispose();
_countryController.dispose();
_provinceController.dispose();
_cityController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final resp = await BusinessApiService.getBusiness(widget.businessId);
_original = resp;
_nameController.text = resp.name;
_addressController.text = resp.address ?? '';
_phoneController.text = resp.phone ?? '';
_mobileController.text = resp.mobile ?? '';
_postalCodeController.text = resp.postalCode ?? '';
_nationalIdController.text = resp.nationalId ?? '';
_registrationNumberController.text = resp.registrationNumber ?? '';
_economicIdController.text = resp.economicId ?? '';
_countryController.text = resp.country ?? '';
_provinceController.text = resp.province ?? '';
_cityController.text = resp.city ?? '';
_businessType = _resolveBusinessType(resp.businessType);
_businessField = _resolveBusinessField(resp.businessField);
} catch (e) {
_error = e.toString();
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
BusinessType? _resolveBusinessType(String value) {
for (final t in BusinessType.values) {
if (t.displayName == value) return t;
}
return null;
}
BusinessField? _resolveBusinessField(String value) {
for (final f in BusinessField.values) {
if (f.displayName == value) return f;
}
return null;
}
Map<String, dynamic> _buildUpdatePayload() {
final orig = _original!;
final payload = <String, dynamic>{};
if (_nameController.text.trim() != orig.name) payload['name'] = _nameController.text.trim();
if (_businessType != null && _businessType!.displayName != orig.businessType) {
payload['business_type'] = _businessType!.displayName;
}
if (_businessField != null && _businessField!.displayName != orig.businessField) {
payload['business_field'] = _businessField!.displayName;
}
final addr = _addressController.text.trim();
if ((orig.address ?? '') != addr) payload['address'] = addr.isEmpty ? null : addr;
final phone = _phoneController.text.trim();
if ((orig.phone ?? '') != phone) payload['phone'] = phone.isEmpty ? null : phone;
final mobile = _mobileController.text.trim();
if ((orig.mobile ?? '') != mobile) payload['mobile'] = mobile.isEmpty ? null : mobile;
final postal = _postalCodeController.text.trim();
if ((orig.postalCode ?? '') != postal) payload['postal_code'] = postal.isEmpty ? null : postal;
final nid = _nationalIdController.text.trim();
if ((orig.nationalId ?? '') != nid) payload['national_id'] = nid.isEmpty ? null : nid;
final reg = _registrationNumberController.text.trim();
if ((orig.registrationNumber ?? '') != reg) payload['registration_number'] = reg.isEmpty ? null : reg;
final eco = _economicIdController.text.trim();
if ((orig.economicId ?? '') != eco) payload['economic_id'] = eco.isEmpty ? null : eco;
final country = _countryController.text.trim();
if ((orig.country ?? '') != country) payload['country'] = country.isEmpty ? null : country;
final province = _provinceController.text.trim();
if ((orig.province ?? '') != province) payload['province'] = province.isEmpty ? null : province;
final city = _cityController.text.trim();
if ((orig.city ?? '') != city) payload['city'] = city.isEmpty ? null : city;
return payload;
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_original == null) return;
final payload = _buildUpdatePayload();
if (payload.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('بدون تغییر')));
}
return;
}
setState(() {
_saving = true;
_error = null;
});
try {
final resp = await _apiClient.put('/api/v1/businesses/${widget.businessId}', data: payload);
if (resp.data['success'] == true) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('با موفقیت ذخیره شد')));
context.go('/business/${widget.businessId}/settings');
}
} else {
throw Exception(resp.data['message'] ?? 'خطا در ذخیره تغییرات');
}
} catch (e) {
setState(() {
_error = e.toString();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_error!)));
}
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final cs = Theme.of(context).colorScheme;
if (_loading) {
return Scaffold(
appBar: AppBar(title: Text(t.businessSettings)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(t.businessSettings)),
body: Center(child: Text(_error!)),
);
}
return Scaffold(
appBar: AppBar(
title: Text(t.businessSettings),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.save),
label: Text(t.save),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(t.generalSettings, cs),
const SizedBox(height: 8),
_buildTextField(controller: _nameController, label: t.businessName, required: true),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildBusinessTypeDropdown(t)),
const SizedBox(width: 12),
Expanded(child: _buildBusinessFieldDropdown(t)),
],
),
const SizedBox(height: 24),
_buildSectionTitle(t.businessContactInfo, cs),
const SizedBox(height: 8),
_buildTextField(controller: _addressController, label: t.address, maxLines: 2),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildTextField(controller: _phoneController, label: t.phone)),
const SizedBox(width: 12),
Expanded(child: _buildTextField(controller: _mobileController, label: t.mobile)),
],
),
const SizedBox(height: 12),
_buildTextField(controller: _postalCodeController, label: t.postalCode),
const SizedBox(height: 24),
_buildSectionTitle(t.businessLegalInfo, cs),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildTextField(controller: _nationalIdController, label: t.nationalId)),
const SizedBox(width: 12),
Expanded(child: _buildTextField(controller: _registrationNumberController, label: t.registrationNumber)),
],
),
const SizedBox(height: 12),
_buildTextField(controller: _economicIdController, label: t.economicId),
const SizedBox(height: 24),
_buildSectionTitle(t.businessGeographicInfo, cs),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildTextField(controller: _countryController, label: t.country)),
const SizedBox(width: 12),
Expanded(child: _buildTextField(controller: _provinceController, label: t.province)),
],
),
const SizedBox(height: 12),
_buildTextField(controller: _cityController, label: t.city),
],
),
),
),
);
}
Widget _buildSectionTitle(String title, ColorScheme cs) {
return Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface));
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
bool required = false,
int maxLines = 1,
}) {
return TextFormField(
controller: controller,
maxLines: maxLines,
decoration: InputDecoration(labelText: label),
validator: (val) {
if (required && (val == null || val.trim().isEmpty)) {
return label;
}
return null;
},
);
}
Widget _buildBusinessTypeDropdown(AppLocalizations t) {
return DropdownButtonFormField<BusinessType>(
value: _businessType,
decoration: InputDecoration(labelText: t.businessType),
items: BusinessType.values
.map((e) => DropdownMenuItem(value: e, child: Text(e.displayName)))
.toList(),
onChanged: (val) => setState(() => _businessType = val),
validator: (val) => val == null ? t.businessType : null,
);
}
Widget _buildBusinessFieldDropdown(AppLocalizations t) {
return DropdownButtonFormField<BusinessField>(
value: _businessField,
decoration: InputDecoration(labelText: t.businessField),
items: BusinessField.values
.map((e) => DropdownMenuItem(value: e, child: Text(e.displayName)))
.toList(),
onChanged: (val) => setState(() => _businessField = val),
validator: (val) => val == null ? t.businessField : null,
);
}
}

View file

@ -0,0 +1,310 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/invoice_list_item.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils;
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/widgets/document/document_details_dialog.dart';
/// صفحه لیست فاکتورها با ویجت جدول عمومی
class InvoicesListPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
const InvoicesListPage({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
});
@override
State<InvoicesListPage> createState() => _InvoicesListPageState();
}
class _InvoicesListPageState extends State<InvoicesListPage> {
final GlobalKey _tableKey = GlobalKey();
String? _selectedInvoiceType;
DateTime? _fromDate;
DateTime? _toDate;
bool? _isProforma; // null=همه، true=پیش فاکتور، false=قطعی
void _refreshData() {
final state = _tableKey.currentState;
if (state != null) {
try {
// ignore: avoid_dynamic_calls
(state as dynamic).refresh();
return;
} catch (_) {}
}
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(t),
_buildFilters(t),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DataTableWidget<InvoiceListItem>(
key: _tableKey,
config: _buildTableConfig(t),
fromJson: (json) => InvoiceListItem.fromJson(json),
calendarController: widget.calendarController,
),
),
),
],
),
),
);
}
Widget _buildHeader(AppLocalizations t) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'فاکتورها',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'مدیریت لیست فاکتورها',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
// دکمه افزودن فاکتور (در آینده به فرم ایجاد وصل می شود)
FilledButton.icon(
onPressed: _onAddNew,
icon: const Icon(Icons.add),
label: Text(t.add),
),
],
),
);
}
Widget _buildFilters(AppLocalizations t) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: SegmentedButton<String?>(
segments: const [
ButtonSegment<String?>(value: null, label: Text('همه'), icon: Icon(Icons.all_inclusive)),
ButtonSegment<String?>(value: 'invoice_sales', label: Text('فروش'), icon: Icon(Icons.sell_outlined)),
ButtonSegment<String?>(value: 'invoice_purchase', label: Text('خرید'), icon: Icon(Icons.shopping_cart_outlined)),
ButtonSegment<String?>(value: 'invoice_sales_return', label: Text('برگشت فروش'), icon: Icon(Icons.undo_outlined)),
ButtonSegment<String?>(value: 'invoice_purchase_return', label: Text('برگشت خرید'), icon: Icon(Icons.undo)),
ButtonSegment<String?>(value: 'invoice_production', label: Text('تولید'), icon: Icon(Icons.factory_outlined)),
ButtonSegment<String?>(value: 'invoice_direct_consumption', label: Text('مصرف مستقیم'), icon: Icon(Icons.dining_outlined)),
ButtonSegment<String?>(value: 'invoice_waste', label: Text('ضایعات'), icon: Icon(Icons.delete_outline)),
],
selected: _selectedInvoiceType != null ? {_selectedInvoiceType} : <String?>{},
onSelectionChanged: (set) {
setState(() => _selectedInvoiceType = set.first);
_refreshData();
},
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 3,
child: Row(
children: [
Expanded(
child: DateInputField(
value: _fromDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _fromDate = date);
_refreshData();
},
labelText: 'از تاریخ',
hintText: 'انتخاب تاریخ شروع',
),
),
const SizedBox(width: 8),
Expanded(
child: DateInputField(
value: _toDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _toDate = date);
_refreshData();
},
labelText: 'تا تاریخ',
hintText: 'انتخاب تاریخ پایان',
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () {
setState(() {
_fromDate = null;
_toDate = null;
});
_refreshData();
},
icon: const Icon(Icons.clear),
tooltip: 'پاک کردن فیلتر تاریخ',
),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: SegmentedButton<bool?>(
segments: const [
ButtonSegment<bool?>(value: null, label: Text('همه')),
ButtonSegment<bool?>(value: true, label: Text('پیش فاکتور')),
ButtonSegment<bool?>(value: false, label: Text('قطعی')),
],
selected: {_isProforma},
onSelectionChanged: (set) {
setState(() => _isProforma = set.first);
_refreshData();
},
),
),
],
),
],
),
);
}
DataTableConfig<InvoiceListItem> _buildTableConfig(AppLocalizations t) {
return DataTableConfig<InvoiceListItem>(
endpoint: '/invoices/business/${widget.businessId}/search',
title: 'فاکتورها',
excelEndpoint: '/invoices/business/${widget.businessId}/export/excel',
pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf',
columns: [
// عملیات
ActionColumn(
'actions',
'عملیات',
actions: [
DataTableAction(
icon: Icons.visibility,
label: 'مشاهده',
onTap: (item) => _onView(item as InvoiceListItem),
),
],
),
// کد سند
TextColumn('code', 'کد', formatter: (item) => item.code, width: ColumnWidth.small),
// نوع
TextColumn('document_type', 'نوع', formatter: (item) => item.documentTypeName, width: ColumnWidth.medium),
// تاریخ سند
DateColumn(
'document_date',
'تاریخ',
width: ColumnWidth.medium,
formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali),
),
// مبلغ کل
NumberColumn(
'total_amount',
'مبلغ کل',
width: ColumnWidth.large,
formatter: (item) => item.totalAmount != null ? formatWithThousands(item.totalAmount!) : '-',
suffix: ' ریال',
),
// ارز
TextColumn('currency_code', 'ارز', formatter: (item) => item.currencyCode ?? 'نامشخص', width: ColumnWidth.small),
// ایجادکننده
TextColumn('created_by_name', 'ایجادکننده', formatter: (item) => item.createdByName ?? 'نامشخص', width: ColumnWidth.medium),
// وضعیت
TextColumn('is_proforma', 'وضعیت', formatter: (item) => item.isProforma ? 'پیش فاکتور' : 'قطعی', width: ColumnWidth.small),
],
searchFields: const ['code', 'description'],
filterFields: const ['document_type'],
dateRangeField: 'document_date',
showSearch: true,
showFilters: true,
showPagination: true,
showColumnSearch: true,
showRefreshButton: true,
showClearFiltersButton: true,
enableRowSelection: false,
enableMultiRowSelection: false,
defaultPageSize: 20,
pageSizeOptions: const [10, 20, 50, 100],
additionalParams: {
'document_type': _selectedInvoiceType,
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
if (_isProforma != null) 'is_proforma': _isProforma,
},
onRowTap: (item) => _onView(item as InvoiceListItem),
emptyStateMessage: 'هیچ فاکتوری یافت نشد',
loadingMessage: 'در حال بارگذاری فاکتورها...',
errorMessage: 'خطا در بارگذاری فاکتورها',
);
}
Future<void> _onAddNew() async {
if (!mounted) return;
await context.pushNamed(
'business_new_invoice',
pathParameters: {
'business_id': widget.businessId.toString(),
},
);
if (!mounted) return;
_refreshData();
}
Future<void> _onView(InvoiceListItem item) async {
await showDialog(
context: context,
builder: (_) => DocumentDetailsDialog(
documentId: item.id,
calendarController: widget.calendarController,
),
);
}
}

View file

@ -7,6 +7,7 @@ import '../../widgets/invoice/invoice_type_combobox.dart';
import '../../widgets/invoice/code_field_widget.dart';
import '../../widgets/invoice/customer_combobox_widget.dart';
import '../../widgets/invoice/seller_picker_widget.dart';
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../widgets/invoice/commission_percentage_field.dart';
import '../../widgets/invoice/commission_type_selector.dart';
import '../../widgets/invoice/commission_amount_field.dart';
@ -49,6 +50,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
final bool _autoGenerateInvoiceNumber = true;
Customer? _selectedCustomer;
Person? _selectedSeller;
Person? _selectedSupplier; // برای فاکتورهای خرید
double? _commissionPercentage;
double? _commissionAmount;
CommissionType? _commissionType;
@ -219,16 +221,27 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
InvoiceTypeCombobox(
selectedType: _selectedInvoiceType,
onTypeChanged: (type) {
setState(() {
_selectedInvoiceType = type;
// بهروزرسانی TabController اگر تعداد تبها تغییر کرده
final newTabCount = _getTabCountForType(type);
if (newTabCount != _tabController.length) {
_tabController.dispose();
_tabController = TabController(length: newTabCount, vsync: this);
}
});
},
setState(() {
_selectedInvoiceType = type;
// پاک کردن انتخابهای قبلی هنگام تغییر نوع فاکتور
if (type == InvoiceType.purchase || type == InvoiceType.purchaseReturn) {
_selectedCustomer = null;
_selectedSeller = null;
} else if (type == InvoiceType.sales || type == InvoiceType.salesReturn) {
_selectedSupplier = null;
} else {
_selectedCustomer = null;
_selectedSupplier = null;
_selectedSeller = null;
}
// بهروزرسانی TabController اگر تعداد تبها تغییر کرده
final newTabCount = _getTabCountForType(type);
if (newTabCount != _tabController.length) {
_tabController.dispose();
_tabController = TabController(length: newTabCount, vsync: this);
}
});
},
isDraft: _isDraft,
onDraftChanged: (isDraft) {
setState(() {
@ -284,23 +297,41 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
),
const SizedBox(height: 16),
// مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی میشود)
if (!(_selectedInvoiceType == InvoiceType.waste ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.production))
// مشتری (فقط برای فروش و برگشت از فروش)
if (_selectedInvoiceType == InvoiceType.sales ||
_selectedInvoiceType == InvoiceType.salesReturn)
CustomerComboboxWidget(
selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer;
});
},
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
label: 'مشتری',
hintText: 'انتخاب مشتری',
),
selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer;
});
},
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
label: 'مشتری',
hintText: 'انتخاب مشتری',
),
// تامینکننده (فقط برای خرید و برگشت از خرید)
if (_selectedInvoiceType == InvoiceType.purchase ||
_selectedInvoiceType == InvoiceType.purchaseReturn) ...[
const SizedBox(height: 16),
PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: _selectedSupplier,
onChanged: (person) {
setState(() {
_selectedSupplier = person;
});
},
isRequired: false,
label: 'تامین‌کننده',
hintText: 'انتخاب تامین‌کننده',
personTypes: ['تامین‌کننده', 'فروشنده'],
searchHint: 'جست‌وجو در تامین‌کنندگان...',
),
],
const SizedBox(height: 16),
// ارز فاکتور
@ -459,6 +490,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
onTypeChanged: (type) {
setState(() {
_selectedInvoiceType = type;
// پاک کردن انتخابهای قبلی هنگام تغییر نوع فاکتور
if (type == InvoiceType.purchase || type == InvoiceType.purchaseReturn) {
_selectedCustomer = null;
_selectedSeller = null;
} else if (type == InvoiceType.sales || type == InvoiceType.salesReturn) {
_selectedSupplier = null;
} else {
_selectedCustomer = null;
_selectedSupplier = null;
_selectedSeller = null;
}
// بهروزرسانی TabController اگر تعداد تبها تغییر کرده
final newTabCount = _getTabCountForType(type);
if (newTabCount != _tabController.length) {
@ -527,19 +569,38 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.production)
? const SizedBox()
: CustomerComboboxWidget(
selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer;
});
},
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
label: 'مشتری',
hintText: 'انتخاب مشتری',
),
: (_selectedInvoiceType == InvoiceType.sales ||
_selectedInvoiceType == InvoiceType.salesReturn)
? CustomerComboboxWidget(
selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer;
});
},
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
label: 'مشتری',
hintText: 'انتخاب مشتری',
)
: (_selectedInvoiceType == InvoiceType.purchase ||
_selectedInvoiceType == InvoiceType.purchaseReturn)
? PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: _selectedSupplier,
onChanged: (person) {
setState(() {
_selectedSupplier = person;
});
},
isRequired: false,
label: 'تامین‌کننده',
hintText: 'انتخاب تامین‌کننده',
personTypes: ['تامین‌کننده', 'فروشنده'],
searchHint: 'جست‌وجو در تامین‌کنندگان...',
)
: const SizedBox(),
),
],
),
@ -779,11 +840,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
}
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
// مشتری برای انواع خاص الزامی نیست
final shouldHaveCustomer = !(_selectedInvoiceType == InvoiceType.waste || _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production);
if (shouldHaveCustomer && _selectedCustomer == null) {
final isPurchaseOrReturn = _selectedInvoiceType == InvoiceType.purchase || _selectedInvoiceType == InvoiceType.purchaseReturn;
// اعتبارسنجی مشتری برای فروش
if (isSalesOrReturn && _selectedCustomer == null) {
return 'انتخاب مشتری الزامی است';
}
// اعتبارسنجی تامینکننده برای خرید
if (isPurchaseOrReturn && _selectedSupplier == null) {
return 'انتخاب تامین‌کننده الزامی است';
}
// اعتبارسنجی کارمزد در حالت فروش
if (isSalesOrReturn && _selectedSeller != null && _commissionType != null) {
@ -796,52 +863,97 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
}
}
// ساخت payload
final payload = <String, dynamic>{
'type': _selectedInvoiceType!.value,
'is_draft': _isDraft,
if (_invoiceNumber != null && _invoiceNumber!.trim().isNotEmpty) 'number': _invoiceNumber!.trim(),
'invoice_date': _invoiceDate!.toIso8601String(),
if (_dueDate != null) 'due_date': _dueDate!.toIso8601String(),
'currency_id': _selectedCurrencyId,
if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'title': _invoiceTitle,
if (_invoiceReference != null && _invoiceReference!.isNotEmpty) 'reference': _invoiceReference,
if (_selectedCustomer != null) 'customer_id': _selectedCustomer!.id,
if (_selectedSeller?.id != null) 'seller_id': _selectedSeller!.id,
if (_commissionType != null) 'commission_type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount',
if (_commissionType == CommissionType.percentage && _commissionPercentage != null) 'commission_percentage': _commissionPercentage,
if (_commissionType == CommissionType.amount && _commissionAmount != null) 'commission_amount': _commissionAmount,
'settings': {
'print_after_save': _printAfterSave,
'printer': _selectedPrinter,
'paper_size': _selectedPaperSize,
'is_official_invoice': _isOfficialInvoice,
'print_template': _selectedPrintTemplate,
'send_to_tax_folder': _sendToTaxFolder,
},
'transactions': _transactions.map((t) => t.toJson()).toList(),
'line_items': _lineItems.map((e) => _serializeLineItem(e)).toList(),
'summary': {
'subtotal': _sumSubtotal,
// تبدیل نوع فاکتور به فرمت API
String _convertInvoiceTypeToApi(InvoiceType type) {
return 'invoice_${type.value}';
}
// ساخت extra_info با person_id و totals
final extraInfo = <String, dynamic>{
'totals': {
'gross': _sumSubtotal,
'discount': _sumDiscount,
'tax': _sumTax,
'total': _sumTotal,
'net': _sumTotal,
},
};
// افزودن person_id بر اساس نوع فاکتور
if (isSalesOrReturn && _selectedCustomer != null) {
extraInfo['person_id'] = _selectedCustomer!.id;
} else if (isPurchaseOrReturn && _selectedSupplier != null) {
extraInfo['person_id'] = _selectedSupplier!.id;
}
// افزودن اطلاعات فروشنده و کارمزد (اختیاری)
if (isSalesOrReturn && _selectedSeller != null) {
extraInfo['seller_id'] = _selectedSeller!.id;
if (_commissionType != null) {
extraInfo['commission'] = {
'type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount',
if (_commissionType == CommissionType.percentage && _commissionPercentage != null)
'value': _commissionPercentage,
if (_commissionType == CommissionType.amount && _commissionAmount != null)
'value': _commissionAmount,
};
}
}
// ساخت payload
final payload = <String, dynamic>{
'invoice_type': _convertInvoiceTypeToApi(_selectedInvoiceType!),
'document_date': _invoiceDate!.toIso8601String().split('T')[0], // فقط تاریخ بدون زمان
'currency_id': _selectedCurrencyId,
'is_proforma': _isDraft,
'extra_info': extraInfo,
if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'description': _invoiceTitle,
'lines': _lineItems.map((e) => _serializeLineItem(e)).toList(),
};
// افزودن payments اگر وجود دارد
if (_transactions.isNotEmpty) {
payload['payments'] = _transactions.map((t) => t.toJson()).toList();
}
return payload;
}
Map<String, dynamic> _serializeLineItem(InvoiceLineItem e) {
// تعیین movement بر اساس نوع فاکتور
String? movement;
if (_selectedInvoiceType == InvoiceType.sales ||
_selectedInvoiceType == InvoiceType.purchaseReturn ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.waste) {
movement = 'out';
} else if (_selectedInvoiceType == InvoiceType.purchase ||
_selectedInvoiceType == InvoiceType.salesReturn) {
movement = 'in';
}
// برای production، movement باید در UI تعیین شود (میتواند out یا in باشد)
// محاسبه مقادیر
final lineDiscount = e.discountAmount;
final taxAmount = e.taxAmount;
final lineTotal = e.total;
return <String, dynamic>{
'product_id': e.productId,
'unit': e.selectedUnit ?? e.mainUnit,
'quantity': e.quantity,
'unit_price': e.unitPrice,
'unit_price_source': e.unitPriceSource,
'discount_type': e.discountType,
'discount_value': e.discountValue,
'tax_rate': e.taxRate,
if ((e.description ?? '').isNotEmpty) 'description': e.description,
'extra_info': {
'unit_price': e.unitPrice,
'line_discount': lineDiscount,
'tax_amount': taxAmount,
'line_total': lineTotal,
if (movement != null) 'movement': movement,
// اطلاعات اضافی برای ردیابی
'unit': e.selectedUnit ?? e.mainUnit,
'unit_price_source': e.unitPriceSource,
'discount_type': e.discountType,
'discount_value': e.discountValue,
'tax_rate': e.taxRate,
},
};
}

View file

@ -54,7 +54,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: t.businessSettings,
subtitle: t.businessSettingsDescription,
icon: Icons.business,
onTap: () => _showBusinessSettingsDialog(context),
onTap: () => context.go('/business/${widget.businessId}/settings/business'),
),
_buildSettingItem(
context,
@ -215,32 +215,7 @@ class _SettingsPageState extends State<SettingsPage> {
}
// دیالوگهای تنظیمات
void _showBusinessSettingsDialog(BuildContext context) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.businessSettings),
content: Text(t.businessSettingsDialogContent),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.close),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
// Navigate to business dashboard for now (until business settings page is created)
context.go('/business/${widget.businessId}/dashboard');
},
child: Text(t.edit),
),
],
),
);
}
void _showPrintDocumentsDialog(BuildContext context) {
final t = AppLocalizations.of(context);
showDialog(

View file

@ -1,5 +1,4 @@
import '../core/api_client.dart';
import '../models/account_model.dart';
class AccountService {
final ApiClient _client;
@ -88,3 +87,4 @@ class AccountService {
}
}
}

View file

@ -48,6 +48,19 @@ class BomService {
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return BomExplosionResult.fromJson(data);
}
Future<Map<String, dynamic>> produceDraft({required int businessId, int? productId, int? bomId, required double quantity, int? currencyId, int? fiscalYearId, String? documentDate}) async {
final payload = <String, dynamic>{
if (productId != null) 'product_id': productId,
if (bomId != null) 'bom_id': bomId,
'quantity': quantity,
if (currencyId != null) 'currency_id': currencyId,
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
if (documentDate != null) 'document_date': documentDate,
};
final res = await _api.post<Map<String, dynamic>>('/api/v1/boms/business/$businessId/produce_draft', data: payload);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
}

View file

@ -0,0 +1,29 @@
import 'package:shared_preferences/shared_preferences.dart';
class ProductionSettingsService {
static String _keyInventoryCode(int businessId) => 'prod_inv_code_$businessId';
static String _keyWipCode(int businessId) => 'prod_wip_code_$businessId';
Future<(String? inventoryCode, String? wipCode)> getDefaultAccounts(int businessId) async {
final prefs = await SharedPreferences.getInstance();
final inv = prefs.getString(_keyInventoryCode(businessId));
final wip = prefs.getString(_keyWipCode(businessId));
return (inv, wip);
}
Future<void> saveDefaultAccounts({required int businessId, String? inventoryCode, String? wipCode}) async {
final prefs = await SharedPreferences.getInstance();
if (inventoryCode == null || inventoryCode.isEmpty) {
await prefs.remove(_keyInventoryCode(businessId));
} else {
await prefs.setString(_keyInventoryCode(businessId), inventoryCode);
}
if (wipCode == null || wipCode.isEmpty) {
await prefs.remove(_keyWipCode(businessId));
} else {
await prefs.setString(_keyWipCode(businessId), wipCode);
}
}
}

View file

@ -8,6 +8,7 @@ import 'package:hesabix_ui/services/document_service.dart';
import 'package:hesabix_ui/services/account_service.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/widgets/document/document_line_editor.dart';
import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart';
/// دیالوگ ایجاد یا ویرایش سند حسابداری دستی
class DocumentFormDialog extends StatefulWidget {
@ -18,6 +19,9 @@ class DocumentFormDialog extends StatefulWidget {
final DocumentModel? document; // null = ایجاد جدید, not null = ویرایش
final int? fiscalYearId;
final int? currencyId;
final List<DocumentLineEdit>? initialLines; // خطوط اولیه (مثلاً پیش نویس تولید)
final String? initialDescription;
final DateTime? initialDocumentDate;
const DocumentFormDialog({
super.key,
@ -28,6 +32,9 @@ class DocumentFormDialog extends StatefulWidget {
this.document,
this.fiscalYearId,
this.currencyId,
this.initialLines,
this.initialDescription,
this.initialDocumentDate,
});
@override
@ -56,18 +63,26 @@ class _DocumentFormDialogState extends State<DocumentFormDialog> {
void initState() {
super.initState();
_service = DocumentService(widget.apiClient);
_currencyId = widget.currencyId ?? 1; // پیشفرض ریال
_documentDate = DateTime.now();
_currencyId = widget.currencyId; // اگر null باشد، CurrencyPickerWidget ارز پیشفرض را از API انتخاب میکند
_documentDate = widget.initialDocumentDate ?? DateTime.now();
// اگر حالت ویرایش است، مقادیر را بارگذاری کن
if (widget.document != null) {
_loadDocumentData();
} else {
// خط خالی برای شروع
_lines = [
DocumentLineEdit(),
DocumentLineEdit(),
];
// اگر خطوط اولیه ارسال شده باشد (مثل پیش نویس تولید) از آن استفاده کن
if (widget.initialLines != null && widget.initialLines!.isNotEmpty) {
_lines = widget.initialLines!.map((e) => e.copy()).toList();
if (widget.initialDescription != null && widget.initialDescription!.isNotEmpty) {
_descriptionController.text = widget.initialDescription!;
}
} else {
// خط خالی برای شروع
_lines = [
DocumentLineEdit(),
DocumentLineEdit(),
];
}
}
}
@ -181,6 +196,14 @@ class _DocumentFormDialogState extends State<DocumentFormDialog> {
return;
}
// بررسی ارز انتخابی
if (_currencyId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('انتخاب ارز الزامی است')),
);
return;
}
// بررسی حداقل 2 سطر
if (_lines.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
@ -418,25 +441,19 @@ class _DocumentFormDialogState extends State<DocumentFormDialog> {
),
const SizedBox(width: 16),
// ارز (ساده شده - در آینده از API بیاید)
// ارز (لیست ارزهای کسبوکار با انتخاب خودکار ارز پیشفرض)
Expanded(
flex: 2,
child: DropdownButtonFormField<int>(
value: _currencyId,
decoration: const InputDecoration(
labelText: 'ارز',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 1, child: Text('ریال')),
DropdownMenuItem(value: 2, child: Text('دلار')),
DropdownMenuItem(value: 3, child: Text('یورو')),
],
child: CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _currencyId,
onChanged: (value) {
setState(() {
_currencyId = value;
});
},
label: 'ارز',
hintText: 'انتخاب ارز',
),
),
const SizedBox(width: 16),

View file

@ -0,0 +1,487 @@
import 'package:flutter/material.dart';
import '../../models/bom_models.dart';
import '../../services/bom_service.dart';
import '../invoice/product_combobox_widget.dart';
class BomEditorDialog extends StatefulWidget {
final int businessId;
final ProductBOM bom;
const BomEditorDialog({super.key, required this.businessId, required this.bom});
@override
State<BomEditorDialog> createState() => _BomEditorDialogState();
}
class _BomEditorDialogState extends State<BomEditorDialog> with SingleTickerProviderStateMixin {
late final BomService _service;
// Header fields
late TextEditingController _nameController;
late TextEditingController _versionController;
bool _isDefault = false;
final TextEditingController _yieldController = TextEditingController();
final TextEditingController _wastageController = TextEditingController();
// Lines
late List<BomItem> _items;
late List<BomOutput> _outputs;
late List<BomOperation> _operations;
late List<Map<String, dynamic>?> _itemSelectedProducts;
late List<Map<String, dynamic>?> _outputSelectedProducts;
bool _saving = false;
late TabController _tabController;
@override
void initState() {
super.initState();
_service = BomService();
_nameController = TextEditingController(text: widget.bom.name);
_versionController = TextEditingController(text: widget.bom.version);
_isDefault = widget.bom.isDefault;
_yieldController.text = widget.bom.yieldPercent?.toString() ?? '';
_wastageController.text = widget.bom.wastagePercent?.toString() ?? '';
_items = List<BomItem>.from(widget.bom.items);
_outputs = List<BomOutput>.from(widget.bom.outputs);
_operations = List<BomOperation>.from(widget.bom.operations);
_itemSelectedProducts = List<Map<String, dynamic>?>.filled(_items.length, null);
_outputSelectedProducts = List<Map<String, dynamic>?>.filled(_outputs.length, null);
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_nameController.dispose();
_versionController.dispose();
_yieldController.dispose();
_wastageController.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: SizedBox(
width: 900,
height: 700,
child: Column(
children: [
_buildHeader(context),
const Divider(height: 1),
_buildTabs(),
Expanded(child: _buildTabViews()),
const Divider(height: 1),
_buildFooter(context),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('ویرایش فرمول تولید', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'عنوان', border: OutlineInputBorder()),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _versionController,
decoration: const InputDecoration(labelText: 'نسخه', border: OutlineInputBorder()),
),
),
const SizedBox(width: 12),
CheckboxListTile(
value: _isDefault,
onChanged: (v) => setState(() => _isDefault = v ?? false),
title: const Text('پیش‌فرض'),
controlAffinity: ListTileControlAffinity.leading,
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _yieldController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'بازده کل (%)', border: OutlineInputBorder()),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _wastageController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'پرت کل (%)', border: OutlineInputBorder()),
),
),
],
)
],
),
);
}
Widget _buildTabs() {
return TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'مواد اولیه'),
Tab(text: 'خروجی‌ها'),
Tab(text: 'عملیات'),
],
);
}
Widget _buildTabViews() {
return TabBarView(
controller: _tabController,
children: [
_buildItemsEditor(),
_buildOutputsEditor(),
_buildOperationsEditor(),
],
);
}
Widget _buildItemsEditor() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: () {
setState(() {
_items = [
..._items,
BomItem(lineNo: (_items.isEmpty ? 1 : (_items.last.lineNo + 1)), componentProductId: 0, qtyPer: 1),
];
_itemSelectedProducts = [..._itemSelectedProducts, null];
});
},
icon: const Icon(Icons.add),
label: const Text('افزودن سطر مواد'),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final it = _items[i];
final lineCtrl = TextEditingController(text: it.lineNo.toString());
final qtyCtrl = TextEditingController(text: it.qtyPer.toString());
final uomCtrl = TextEditingController(text: it.uom ?? '');
final wastCtrl = TextEditingController(text: it.wastagePercent?.toString() ?? '');
final substCtrl = TextEditingController(text: it.substituteGroup ?? '');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
_num(lineCtrl, 'ردیف', (v) => _updateItem(i, lineNo: int.tryParse(v))),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: ProductComboboxWidget(
businessId: widget.businessId,
selectedProduct: _itemSelectedProducts[i],
label: 'کالا',
hintText: 'جست‌وجوی کالا',
onChanged: (p) {
setState(() {
_itemSelectedProducts[i] = p;
final pid = p == null ? null : (p['id'] as int?);
if (pid != null) {
_updateItem(i, componentProductId: pid);
}
});
},
),
),
const SizedBox(width: 8),
_num(qtyCtrl, 'مقدار برای 1 واحد', (v) => _updateItem(i, qtyPer: double.tryParse(v))),
const SizedBox(width: 8),
_text(uomCtrl, 'واحد', (v) => _updateItem(i, uom: v.isEmpty ? null : v)),
const SizedBox(width: 8),
_num(wastCtrl, 'پرت (%)', (v) => _updateItem(i, wastagePercent: double.tryParse(v))),
const SizedBox(width: 8),
_text(substCtrl, 'گروه جایگزین', (v) => _updateItem(i, substituteGroup: v.isEmpty ? null : v)),
IconButton(onPressed: () => setState(() { _items.removeAt(i); _itemSelectedProducts.removeAt(i); }), icon: const Icon(Icons.delete_outline)),
],
),
);
},
),
),
],
),
);
}
Widget _buildOutputsEditor() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: () {
setState(() {
_outputs = [
..._outputs,
BomOutput(lineNo: (_outputs.isEmpty ? 1 : (_outputs.last.lineNo + 1)), outputProductId: 0, ratio: 1),
];
_outputSelectedProducts = [..._outputSelectedProducts, null];
});
},
icon: const Icon(Icons.add),
label: const Text('افزودن سطر خروجی'),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: _outputs.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final ot = _outputs[i];
final lineCtrl = TextEditingController(text: ot.lineNo.toString());
final ratioCtrl = TextEditingController(text: ot.ratio.toString());
final uomCtrl = TextEditingController(text: ot.uom ?? '');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
_num(lineCtrl, 'ردیف', (v) => _updateOutput(i, lineNo: int.tryParse(v))),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: ProductComboboxWidget(
businessId: widget.businessId,
selectedProduct: _outputSelectedProducts[i],
label: 'محصول خروجی',
hintText: 'جست‌وجوی محصول خروجی',
onChanged: (p) {
setState(() {
_outputSelectedProducts[i] = p;
final pid = p == null ? null : (p['id'] as int?);
if (pid != null) {
_updateOutput(i, outputProductId: pid);
}
});
},
),
),
const SizedBox(width: 8),
_num(ratioCtrl, 'نسبت', (v) => _updateOutput(i, ratio: double.tryParse(v))),
const SizedBox(width: 8),
_text(uomCtrl, 'واحد', (v) => _updateOutput(i, uom: v.isEmpty ? null : v)),
IconButton(onPressed: () => setState(() { _outputs.removeAt(i); _outputSelectedProducts.removeAt(i); }), icon: const Icon(Icons.delete_outline)),
],
),
);
},
),
),
],
),
);
}
Widget _buildOperationsEditor() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: () {
setState(() {
_operations = [
..._operations,
const BomOperation(lineNo: 1, operationName: ''),
];
});
},
icon: const Icon(Icons.add),
label: const Text('افزودن عملیات'),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: _operations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final op = _operations[i];
final lineCtrl = TextEditingController(text: op.lineNo.toString());
final nameCtrl = TextEditingController(text: op.operationName);
final fixedCtrl = TextEditingController(text: op.costFixed?.toString() ?? '');
final perCtrl = TextEditingController(text: op.costPerUnit?.toString() ?? '');
final uomCtrl = TextEditingController(text: op.costUom ?? '');
final wcCtrl = TextEditingController(text: op.workCenter ?? '');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
_num(lineCtrl, 'ردیف', (v) => _updateOperation(i, lineNo: int.tryParse(v))),
const SizedBox(width: 8),
_text(nameCtrl, 'نام عملیات', (v) => _updateOperation(i, operationName: v)),
const SizedBox(width: 8),
_num(fixedCtrl, 'هزینه ثابت', (v) => _updateOperation(i, costFixed: double.tryParse(v))),
const SizedBox(width: 8),
_num(perCtrl, 'هزینه واحد', (v) => _updateOperation(i, costPerUnit: double.tryParse(v))),
const SizedBox(width: 8),
_text(uomCtrl, 'واحد هزینه', (v) => _updateOperation(i, costUom: v.isEmpty ? null : v)),
const SizedBox(width: 8),
_text(wcCtrl, 'ایستگاه کاری', (v) => _updateOperation(i, workCenter: v.isEmpty ? null : v)),
IconButton(onPressed: () => setState(() => _operations.removeAt(i)), icon: const Icon(Icons.delete_outline)),
],
),
);
},
),
),
],
),
);
}
Widget _buildFooter(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('انصراف'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.save),
label: const Text('ذخیره'),
),
],
),
);
}
Widget _num(TextEditingController c, String label, void Function(String) onChanged) {
return Expanded(
child: TextField(
controller: c,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
onChanged: onChanged,
),
);
}
Widget _text(TextEditingController c, String label, void Function(String) onChanged) {
return Expanded(
child: TextField(
controller: c,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
onChanged: onChanged,
),
);
}
void _updateItem(int index, {int? lineNo, int? componentProductId, double? qtyPer, String? uom, double? wastagePercent, String? substituteGroup}) {
final current = _items[index];
setState(() {
_items[index] = BomItem(
lineNo: lineNo ?? current.lineNo,
componentProductId: componentProductId ?? current.componentProductId,
qtyPer: qtyPer ?? current.qtyPer,
uom: uom ?? current.uom,
wastagePercent: wastagePercent ?? current.wastagePercent,
isOptional: current.isOptional,
substituteGroup: substituteGroup ?? current.substituteGroup,
suggestedWarehouseId: current.suggestedWarehouseId,
);
});
}
void _updateOutput(int index, {int? lineNo, int? outputProductId, double? ratio, String? uom}) {
final current = _outputs[index];
setState(() {
_outputs[index] = BomOutput(
lineNo: lineNo ?? current.lineNo,
outputProductId: outputProductId ?? current.outputProductId,
ratio: ratio ?? current.ratio,
uom: uom ?? current.uom,
outputProductName: current.outputProductName,
outputProductCode: current.outputProductCode,
);
});
}
void _updateOperation(int index, {int? lineNo, String? operationName, double? costFixed, double? costPerUnit, String? costUom, String? workCenter}) {
final current = _operations[index];
setState(() {
_operations[index] = BomOperation(
lineNo: lineNo ?? current.lineNo,
operationName: operationName ?? current.operationName,
costFixed: costFixed ?? current.costFixed,
costPerUnit: costPerUnit ?? current.costPerUnit,
costUom: costUom ?? current.costUom,
workCenter: workCenter ?? current.workCenter,
);
});
}
Future<void> _save() async {
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'version': _versionController.text.trim(),
'name': _nameController.text.trim(),
'is_default': _isDefault,
'yield_percent': _yieldController.text.trim().isEmpty ? null : double.tryParse(_yieldController.text.replaceAll(',', '.')),
'wastage_percent': _wastageController.text.trim().isEmpty ? null : double.tryParse(_wastageController.text.replaceAll(',', '.')),
'items': _items.map((e) => e.toJson()).toList(),
'outputs': _outputs.map((e) => e.toJson()).toList(),
'operations': _operations.map((e) => e.toJson()).toList(),
};
final updated = await _service.update(
businessId: widget.businessId,
bomId: widget.bom.id!,
payload: payload,
);
if (!mounted) return;
Navigator.of(context).pop<ProductBOM>(updated);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ذخیره: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
}

View file

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../../services/production_settings_service.dart';
class ProductionSettingsDialog extends StatefulWidget {
final int businessId;
const ProductionSettingsDialog({super.key, required this.businessId});
@override
State<ProductionSettingsDialog> createState() => _ProductionSettingsDialogState();
}
class _ProductionSettingsDialogState extends State<ProductionSettingsDialog> {
final _service = ProductionSettingsService();
final _invCtrl = TextEditingController();
final _wipCtrl = TextEditingController();
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final (inv, wip) = await _service.getDefaultAccounts(widget.businessId);
if (!mounted) return;
setState(() {
_invCtrl.text = inv ?? '10102';
_wipCtrl.text = wip ?? '10106';
_loading = false;
});
}
@override
void dispose() {
_invCtrl.dispose();
_wipCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('تنظیمات حساب‌های تولید', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (_loading) const Center(child: CircularProgressIndicator()) else ...[
TextField(
controller: _invCtrl,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'کد حساب موجودی کالا (مصرف مواد)'
),
),
const SizedBox(height: 10),
TextField(
controller: _wipCtrl,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'کد حساب کالای در جریان ساخت/محصول تولیدی'
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('انصراف')),
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
await _service.saveDefaultAccounts(
businessId: widget.businessId,
inventoryCode: _invCtrl.text.trim(),
wipCode: _wipCtrl.text.trim(),
);
if (!mounted) return;
Navigator.pop(context, true);
},
child: const Text('ذخیره'),
),
],
)
],
],
),
),
),
);
}
}

View file

@ -1,6 +1,16 @@
import 'package:flutter/material.dart';
import '../../../services/bom_service.dart';
import '../../../models/bom_models.dart';
import '../../product/bom_editor_dialog.dart';
import '../../../core/api_client.dart';
import '../../../core/auth_store.dart';
import '../../../core/calendar_controller.dart';
import '../../document/document_form_dialog.dart';
import '../../document/document_line_editor.dart';
import '../../../services/account_service.dart';
import '../../../models/account_model.dart';
import '../production_settings_dialog.dart';
import '../../../services/production_settings_service.dart';
class ProductBomSection extends StatefulWidget {
final int businessId;
@ -69,6 +79,18 @@ class _ProductBomSectionState extends State<ProductBomSection> {
children: [
Text('فرمول‌های تولید', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
Tooltip(
message: 'تنظیمات تولید',
child: IconButton(
onPressed: () async {
await showDialog<bool>(
context: context,
builder: (_) => ProductionSettingsDialog(businessId: widget.businessId),
);
},
icon: const Icon(Icons.settings_suggest_outlined),
),
),
FilledButton.icon(
onPressed: _showCreateDialog,
icon: const Icon(Icons.add),
@ -95,6 +117,11 @@ class _ProductBomSectionState extends State<ProductBomSection> {
icon: const Icon(Icons.auto_awesome),
onPressed: () => _explode(bom),
),
IconButton(
tooltip: 'ویرایش جزئیات',
icon: const Icon(Icons.tune),
onPressed: () => _openEditor(bom),
),
IconButton(
tooltip: 'ویرایش',
icon: const Icon(Icons.edit),
@ -127,16 +154,37 @@ class _ProductBomSectionState extends State<ProductBomSection> {
Future<void> _explode(ProductBOM bom) async {
try {
// دریافت مقدار تولید از کاربر
final qtyController = TextEditingController(text: '1');
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('انفجار فرمول')
,
content: TextField(
controller: qtyController,
decoration: const InputDecoration(labelText: 'مقدار تولید', hintText: 'مثلاً 10'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ادامه')),
],
),
);
if (ok != true) return;
final qty = double.tryParse(qtyController.text.replaceAll(',', '.')) ?? 1;
final result = await _service.explode(
businessId: widget.businessId,
bomId: bom.id,
quantity: 1,
quantity: qty,
);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('خروجی انفجار فرمول (برای ۱ واحد)'),
title: Text('خروجی انفجار فرمول (برای ${qty.toString()} واحد)'),
content: SizedBox(
width: 500,
child: Column(
@ -151,7 +199,29 @@ class _ProductBomSectionState extends State<ProductBomSection> {
itemCount: result.items.length,
itemBuilder: (ctx, i) {
final it = result.items[i];
return Text('- ${it.componentProductId} × ${it.requiredQty} ${it.uom ?? ''}');
final name = it.componentProductName ?? '#${it.componentProductId}';
final unit = it.uom ?? it.componentProductMainUnit ?? '';
final mainUnit = it.mainUnit ?? it.componentProductMainUnit ?? '';
final showConv = it.requiredQtyMainUnit != null && (unit != mainUnit) && mainUnit.isNotEmpty;
final convText = showConv ? ' (≈ ${it.requiredQtyMainUnit} $mainUnit)' : '';
return Text('- $name × ${it.requiredQty} ${unit.isEmpty ? '' : unit}$convText');
},
),
),
const SizedBox(height: 16),
const Text('خروجی‌ها:'),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.builder(
itemCount: result.outputs.length,
itemBuilder: (ctx, i) {
final ot = result.outputs[i];
final name = ot.outputProductName ?? '#${ot.outputProductId}';
final mainUnit = ot.mainUnit;
final showConv = ot.ratioMainUnit != null && mainUnit != null && (ot.uom ?? '') != mainUnit;
final convText = showConv ? ' (≈ ${ot.ratioMainUnit} $mainUnit)' : '';
return Text('- $name: ${ot.ratio} ${ot.uom ?? ''}$convText');
},
),
),
@ -160,6 +230,87 @@ class _ProductBomSectionState extends State<ProductBomSection> {
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('بستن')),
FilledButton.icon(
onPressed: () async {
try {
final draft = await _service.produceDraft(
businessId: widget.businessId,
bomId: bom.id,
quantity: qty,
);
if (!mounted) return;
Navigator.of(context).pop();
// یافتن حسابهای پیشفرض
final accountService = AccountService(client: ApiClient());
final prodSettings = ProductionSettingsService();
final (savedInvCode, savedWipCode) = await prodSettings.getDefaultAccounts(widget.businessId);
Future<Account?> _getAccountByCode(String code) async {
try {
final res = await accountService.searchAccounts(businessId: widget.businessId, searchQuery: code, limit: 10);
final items = (res['items'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => Account.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
// جستجوی دقیق بر اساس کد
final exact = items.where((a) => a.code == code).toList();
if (exact.isNotEmpty) return exact.first;
return items.isNotEmpty ? items.first : null;
} catch (_) {
return null;
}
}
final inventoryAccount = await _getAccountByCode((savedInvCode ?? '10102'));
final wipAccount = await _getAccountByCode((savedWipCode ?? '10106'));
// ساخت خطوط اولیه از پیشنویس برای فرم سند
final lines = <DocumentLineEdit>[];
final draftLines = (draft['lines'] as List?) ?? const <dynamic>[];
for (final raw in draftLines) {
final m = Map<String, dynamic>.from(raw as Map);
final isConsumption = (m['description']?.toString() ?? '').contains('مصرف');
final Account? defaultAccount = isConsumption
? inventoryAccount
: (wipAccount ?? inventoryAccount);
lines.add(
DocumentLineEdit(
account: defaultAccount,
detail: {
if (m['product_id'] != null) 'product_id': m['product_id'],
},
quantity: m['quantity'] is num ? (m['quantity'] as num).toDouble() : double.tryParse(m['quantity']?.toString() ?? ''),
debit: 0,
credit: 0,
description: m['description']?.toString(),
),
);
}
// بارگذاری کنترلر تقویم (در صورت عدم وجود)
final calendarController = await CalendarController.load();
// باز کردن فرم سند با مقداردهی اولیه
await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => DocumentFormDialog(
businessId: widget.businessId,
calendarController: calendarController,
authStore: AuthStore(),
apiClient: ApiClient(),
fiscalYearId: null,
currencyId: null,
initialLines: lines,
initialDescription: draft['description']?.toString(),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ایجاد پیش‌نویس: $e')));
}
},
icon: const Icon(Icons.playlist_add),
label: const Text('ایجاد پیش‌نویس سند تولید'),
),
],
),
);
@ -274,6 +425,18 @@ class _ProductBomSectionState extends State<ProductBomSection> {
}
}
Future<void> _openEditor(ProductBOM bom) async {
final updated = await showDialog<ProductBOM>(
context: context,
builder: (_) => BomEditorDialog(businessId: widget.businessId, bom: bom),
);
if (updated != null && mounted) {
setState(() {
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
});
}
}
Future<void> _delete(ProductBOM bom) async {
final ok = await showDialog<bool>(
context: context,