progress in invoices
This commit is contained in:
parent
56ba9a74e8
commit
9701fa31b2
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
106
hesabixAPI/app/services/account_service.py
Normal file
106
hesabixAPI/app/services/account_service.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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)},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
1059
hesabixAPI/app/services/invoice_service.py
Normal file
1059
hesabixAPI/app/services/invoice_service.py
Normal file
File diff suppressed because it is too large
Load diff
432
hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md
Normal file
432
hesabixAPI/docs/INVOICE_ISSUES_AND_SCENARIOS.md
Normal 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)
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart
Normal file
60
hesabixUI/hesabix_ui/lib/models/invoice_list_item.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
310
hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart
Normal file
310
hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>{});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
487
hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart
Normal file
487
hesabixUI/hesabix_ui/lib/widgets/product/bom_editor_dialog.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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('ذخیره'),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue