progress in persons
This commit is contained in:
parent
bd34093dac
commit
a8e5c3d14c
57
hesabixAPI/adapters/api/v1/accounts.py
Normal file
57
hesabixAPI/adapters/api/v1/accounts.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from typing import List, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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 app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access
|
||||
from adapters.db.models.account import Account
|
||||
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
|
||||
by_id: dict[int, AccountTreeNode] = {}
|
||||
roots: list[AccountTreeNode] = []
|
||||
for n in nodes:
|
||||
node = AccountTreeNode(
|
||||
id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
|
||||
)
|
||||
by_id[node.id] = node
|
||||
for node in list(by_id.values()):
|
||||
pid = node.parent_id
|
||||
if pid and pid in by_id:
|
||||
by_id[pid].children.append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
return roots
|
||||
|
||||
|
||||
@router.get("/business/{business_id}/tree",
|
||||
summary="دریافت درخت حسابها برای یک کسب و کار",
|
||||
description="لیست حسابهای عمومی و حسابهای اختصاصی کسب و کار به صورت درختی",
|
||||
)
|
||||
@require_business_access("business_id")
|
||||
def get_accounts_tree(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
# دریافت حسابهای عمومی (business_id IS NULL) و حسابهای مختص این کسب و کار
|
||||
rows = db.query(Account).filter(
|
||||
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
|
||||
).order_by(Account.code.asc()).all()
|
||||
flat = [
|
||||
{"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
|
||||
for r in rows
|
||||
]
|
||||
tree = _build_tree(flat)
|
||||
return success_response({"items": [n.model_dump() for n in tree]}, request)
|
||||
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from adapters.api.v1.schema_models.person import (
|
||||
|
|
@ -16,6 +16,7 @@ from app.services.person_service import (
|
|||
update_person, delete_person, get_person_summary
|
||||
)
|
||||
from adapters.db.models.person import Person
|
||||
from adapters.db.models.business import Business
|
||||
|
||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
||||
|
||||
|
|
@ -132,6 +133,332 @@ async def get_persons_endpoint(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/businesses/{business_id}/persons/export/excel",
|
||||
summary="خروجی Excel لیست اشخاص",
|
||||
description="خروجی Excel لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||
)
|
||||
async def export_persons_excel(
|
||||
business_id: int,
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
auth_context: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import io
|
||||
import json
|
||||
import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from fastapi.responses import Response
|
||||
|
||||
# Build query dict similar to list endpoint from flat body
|
||||
query_dict = {
|
||||
"take": int(body.get("take", 20)),
|
||||
"skip": int(body.get("skip", 0)),
|
||||
"sort_by": body.get("sort_by"),
|
||||
"sort_desc": bool(body.get("sort_desc", False)),
|
||||
"search": body.get("search"),
|
||||
"search_fields": body.get("search_fields"),
|
||||
"filters": body.get("filters"),
|
||||
}
|
||||
|
||||
result = get_persons_by_business(db, business_id, query_dict)
|
||||
|
||||
items = result.get('items', [])
|
||||
# Format date/time fields using existing helper
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
|
||||
# Apply selected indices filter if requested
|
||||
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 headers based on export_columns (order + visibility)
|
||||
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:
|
||||
# Fallback to item keys if no columns provided
|
||||
if items:
|
||||
keys = list(items[0].keys())
|
||||
headers = keys
|
||||
|
||||
# Create workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Persons"
|
||||
|
||||
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'))
|
||||
|
||||
# Write 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
|
||||
|
||||
# Write 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)
|
||||
ws.cell(row=row_idx, column=col_idx, value=value).border = border
|
||||
|
||||
# Auto-width columns
|
||||
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 = f"persons_export_{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)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/businesses/{business_id}/persons/export/pdf",
|
||||
summary="خروجی PDF لیست اشخاص",
|
||||
description="خروجی PDF لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||
)
|
||||
async def export_persons_pdf(
|
||||
business_id: int,
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
auth_context: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
import datetime
|
||||
from fastapi.responses import Response
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
|
||||
# Build query dict from flat body
|
||||
query_dict = {
|
||||
"take": int(body.get("take", 20)),
|
||||
"skip": int(body.get("skip", 0)),
|
||||
"sort_by": body.get("sort_by"),
|
||||
"sort_desc": bool(body.get("sort_desc", False)),
|
||||
"search": body.get("search"),
|
||||
"search_fields": body.get("search_fields"),
|
||||
"filters": body.get("filters"),
|
||||
}
|
||||
|
||||
result = get_persons_by_business(db, business_id, query_dict)
|
||||
items = result.get('items', [])
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
|
||||
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)]
|
||||
|
||||
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:
|
||||
if items:
|
||||
keys = list(items[0].keys())
|
||||
headers = keys
|
||||
|
||||
# Load business info for header
|
||||
business_name = ""
|
||||
try:
|
||||
biz = db.query(Business).filter(Business.id == business_id).first()
|
||||
if biz is not None:
|
||||
business_name = biz.name
|
||||
except Exception:
|
||||
business_name = ""
|
||||
|
||||
# Styled HTML (A4 landscape, RTL)
|
||||
def escape(s: Any) -> str:
|
||||
try:
|
||||
return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
except Exception:
|
||||
return str(s)
|
||||
|
||||
rows_html = []
|
||||
for item in items:
|
||||
tds = []
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
value = ""
|
||||
elif isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
tds.append(f"<td>{escape(value)}</td>")
|
||||
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||
|
||||
headers_html = ''.join(f"<th>{escape(h)}</th>" for h in headers)
|
||||
# Format report datetime based on X-Calendar-Type header
|
||||
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
|
||||
try:
|
||||
from app.core.calendar import CalendarConverter
|
||||
formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
|
||||
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
|
||||
now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
|
||||
except Exception:
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
table_html = f"""
|
||||
<html lang=\"fa\" dir=\"rtl\">
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<style>
|
||||
@page {{
|
||||
size: A4 landscape;
|
||||
margin: 12mm;
|
||||
@bottom-right {{
|
||||
content: "صفحه " counter(page) " از " counter(pages);
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}}
|
||||
}}
|
||||
body {{
|
||||
font-family: sans-serif;
|
||||
font-size: 11px;
|
||||
color: #222;
|
||||
}}
|
||||
.header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 2px solid #444;
|
||||
padding-bottom: 6px;
|
||||
}}
|
||||
.title {{
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.meta {{
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
}}
|
||||
.table-wrapper {{
|
||||
width: 100%;
|
||||
}}
|
||||
table.report-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}}
|
||||
thead th {{
|
||||
background: #f0f3f7;
|
||||
border: 1px solid #c7cdd6;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
tbody td {{
|
||||
border: 1px solid #d7dde6;
|
||||
padding: 5px 4px;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}}
|
||||
.footer {{
|
||||
position: running(footer);
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=\"header\">
|
||||
<div>
|
||||
<div class=\"title\">گزارش لیست اشخاص</div>
|
||||
<div class=\"meta\">نام کسبوکار: {escape(business_name)}</div>
|
||||
</div>
|
||||
<div class=\"meta\">تاریخ گزارش: {escape(now)}</div>
|
||||
</div>
|
||||
<div class=\"table-wrapper\">
|
||||
<table class=\"report-table\">
|
||||
<thead>
|
||||
<tr>{headers_html}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(rows_html)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class=\"footer\">تولید شده توسط Hesabix</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
|
||||
filename = f"persons_export_{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)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/persons/{person_id}",
|
||||
summary="جزئیات شخص",
|
||||
description="دریافت جزئیات یک شخص",
|
||||
|
|
|
|||
19
hesabixAPI/adapters/api/v1/schema_models/account.py
Normal file
19
hesabixAPI/adapters/api/v1/schema_models/account.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccountTreeNode(BaseModel):
|
||||
id: int = Field(..., description="ID حساب")
|
||||
code: str = Field(..., description="کد حساب")
|
||||
name: str = Field(..., description="نام حساب")
|
||||
account_type: Optional[str] = Field(default=None, description="نوع حساب")
|
||||
parent_id: Optional[int] = Field(default=None, description="شناسه والد")
|
||||
level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
|
||||
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ class PersonType(str, Enum):
|
|||
SUPPLIER = "تامینکننده"
|
||||
PARTNER = "همکار"
|
||||
SELLER = "فروشنده"
|
||||
SHAREHOLDER = "سهامدار"
|
||||
|
||||
|
||||
class PersonBankAccountCreateRequest(BaseModel):
|
||||
|
|
@ -78,6 +79,38 @@ class PersonCreateRequest(BaseModel):
|
|||
|
||||
# حسابهای بانکی
|
||||
bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حسابهای بانکی")
|
||||
# سهام
|
||||
share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار، اجباری و حداقل 1)")
|
||||
# پورسانت (برای بازاریاب/فروشنده)
|
||||
commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
|
||||
commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
|
||||
commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
|
||||
commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
|
||||
commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
|
||||
commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
|
||||
commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
yield from super().__get_validators__()
|
||||
|
||||
@staticmethod
|
||||
def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
|
||||
if person_type == PersonType.SHAREHOLDER:
|
||||
return True
|
||||
if person_types:
|
||||
return PersonType.SHAREHOLDER in person_types
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def validate(cls, value): # type: ignore[override]
|
||||
obj = super().validate(value)
|
||||
# اعتبارسنجی شرطی سهامدار
|
||||
if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
|
||||
sc = getattr(obj, 'share_count', None)
|
||||
if sc is None or (isinstance(sc, int) and sc <= 0):
|
||||
raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
|
||||
return obj
|
||||
|
||||
|
||||
class PersonUpdateRequest(BaseModel):
|
||||
|
|
@ -111,6 +144,38 @@ class PersonUpdateRequest(BaseModel):
|
|||
|
||||
# وضعیت
|
||||
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
|
||||
# سهام
|
||||
share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)")
|
||||
# پورسانت
|
||||
commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
|
||||
commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
|
||||
commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
|
||||
commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
|
||||
commission_exclude_discounts: Optional[bool] = Field(default=None, description="عدم محاسبه تخفیف")
|
||||
commission_exclude_additions_deductions: Optional[bool] = Field(default=None, description="عدم محاسبه اضافات و کسورات")
|
||||
commission_post_in_invoice_document: Optional[bool] = Field(default=None, description="ثبت پورسانت در سند فاکتور")
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
yield from super().__get_validators__()
|
||||
|
||||
@staticmethod
|
||||
def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
|
||||
if person_type == PersonType.SHAREHOLDER:
|
||||
return True
|
||||
if person_types:
|
||||
return PersonType.SHAREHOLDER in person_types
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def validate(cls, value): # type: ignore[override]
|
||||
obj = super().validate(value)
|
||||
# اگر ورودیها مشخصاً به سهامدار اشاره دارند، share_count باید معتبر باشد
|
||||
if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
|
||||
sc = getattr(obj, 'share_count', None)
|
||||
if sc is None or (isinstance(sc, int) and sc <= 0):
|
||||
raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
|
||||
return obj
|
||||
|
||||
|
||||
class PersonResponse(BaseModel):
|
||||
|
|
@ -154,6 +219,16 @@ class PersonResponse(BaseModel):
|
|||
|
||||
# حسابهای بانکی
|
||||
bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حسابهای بانکی")
|
||||
# سهام
|
||||
share_count: Optional[int] = Field(default=None, description="تعداد سهام")
|
||||
# پورسانت
|
||||
commission_sale_percent: Optional[float] = Field(default=None, description="درصد پورسانت از فروش")
|
||||
commission_sales_return_percent: Optional[float] = Field(default=None, description="درصد پورسانت از برگشت از فروش")
|
||||
commission_sales_amount: Optional[float] = Field(default=None, description="مبلغ فروش مبنا")
|
||||
commission_sales_return_amount: Optional[float] = Field(default=None, description="مبلغ برگشت از فروش مبنا")
|
||||
commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
|
||||
commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
|
||||
commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from typing import Any, List, Optional, Union, Generic, TypeVar
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
|
@ -177,6 +177,7 @@ class BusinessCreateRequest(BaseModel):
|
|||
province: Optional[str] = Field(default=None, max_length=100, description="استان")
|
||||
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
|
||||
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
|
||||
fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سالهای مالی برای ایجاد اولیه")
|
||||
|
||||
|
||||
class BusinessUpdateRequest(BaseModel):
|
||||
|
|
@ -248,6 +249,14 @@ class PaginatedResponse(BaseModel, Generic[T]):
|
|||
)
|
||||
|
||||
|
||||
# Fiscal Year Schemas
|
||||
class FiscalYearCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی")
|
||||
start_date: date = Field(..., description="تاریخ شروع سال مالی")
|
||||
end_date: date = Field(..., description="تاریخ پایان سال مالی")
|
||||
is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟")
|
||||
|
||||
|
||||
# Business User Schemas
|
||||
class BusinessUserSchema(BaseModel):
|
||||
id: int
|
||||
|
|
|
|||
|
|
@ -20,3 +20,13 @@ from .file_storage import *
|
|||
from .email_config import EmailConfig # noqa: F401, F403
|
||||
|
||||
|
||||
# Accounting / Fiscal models
|
||||
from .fiscal_year import FiscalYear # noqa: F401
|
||||
|
||||
# Currency models
|
||||
from .currency import Currency, BusinessCurrency # noqa: F401
|
||||
|
||||
# Documents
|
||||
from .document import Document # noqa: F401
|
||||
from .document_line import DocumentLine # noqa: F401
|
||||
from .account import Account # noqa: F401
|
||||
|
|
|
|||
32
hesabixAPI/adapters/db/models/account.py
Normal file
32
hesabixAPI/adapters/db/models/account.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('business_id', 'code', name='uq_accounts_business_code'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
business_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
account_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="accounts")
|
||||
parent = relationship("Account", remote_side="Account.id", back_populates="children")
|
||||
children = relationship("Account", back_populates="parent", cascade="all, delete-orphan")
|
||||
document_lines = relationship("DocumentLine", back_populates="account")
|
||||
|
||||
|
||||
|
|
@ -56,3 +56,7 @@ class Business(Base):
|
|||
|
||||
# Relationships
|
||||
persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")
|
||||
fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
|
||||
currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
|
||||
documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
|
||||
accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")
|
||||
|
|
|
|||
43
hesabixAPI/adapters/db/models/currency.py
Normal file
43
hesabixAPI/adapters/db/models/currency.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class Currency(Base):
|
||||
__tablename__ = "currencies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', name='uq_currencies_name'),
|
||||
UniqueConstraint('code', name='uq_currencies_code'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
symbol: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(16), nullable=False) # نام کوتاه
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
businesses = relationship("Business", secondary="business_currencies", back_populates="currencies")
|
||||
documents = relationship("Document", back_populates="currency")
|
||||
|
||||
|
||||
class BusinessCurrency(Base):
|
||||
__tablename__ = "business_currencies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('business_id', 'currency_id', name='uq_business_currencies_business_currency'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
37
hesabixAPI/adapters/db/models/document.py
Normal file
37
hesabixAPI/adapters/db/models/document.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('business_id', 'code', name='uq_documents_business_code'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
document_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
developer_settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="documents")
|
||||
currency = relationship("Currency", back_populates="documents")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
30
hesabixAPI/adapters/db/models/document_line.py
Normal file
30
hesabixAPI/adapters/db/models/document_line.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Integer, DateTime, ForeignKey, JSON, Text, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class DocumentLine(Base):
|
||||
__tablename__ = "document_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
document = relationship("Document", back_populates="lines")
|
||||
account = relationship("Account", back_populates="document_lines")
|
||||
|
||||
|
||||
26
hesabixAPI/adapters/db/models/fiscal_year.py
Normal file
26
hesabixAPI/adapters/db/models/fiscal_year.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import String, Date, DateTime, Integer, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class FiscalYear(Base):
|
||||
__tablename__ = "fiscal_years"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
is_last: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="fiscal_years")
|
||||
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint, Numeric, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
|
@ -17,6 +17,7 @@ class PersonType(str, Enum):
|
|||
SUPPLIER = "تامینکننده" # تامینکننده
|
||||
PARTNER = "همکار" # همکار
|
||||
SELLER = "فروشنده" # فروشنده
|
||||
SHAREHOLDER = "سهامدار" # سهامدار
|
||||
|
||||
|
||||
class Person(Base):
|
||||
|
|
@ -33,10 +34,25 @@ class Person(Base):
|
|||
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
|
||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
|
||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
|
||||
person_type: Mapped[PersonType] = mapped_column(SQLEnum(PersonType), nullable=False, comment="نوع شخص")
|
||||
person_type: Mapped[PersonType] = mapped_column(
|
||||
SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
|
||||
nullable=False,
|
||||
comment="نوع شخص"
|
||||
)
|
||||
person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
|
||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
|
||||
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
|
||||
# سهام
|
||||
share_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="تعداد سهام (فقط برای سهامدار)")
|
||||
|
||||
# تنظیمات پورسانت برای بازاریاب/فروشنده
|
||||
commission_sale_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از فروش")
|
||||
commission_sales_return_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از برگشت از فروش")
|
||||
commission_sales_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ فروش مبنا برای پورسانت")
|
||||
commission_sales_return_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ برگشت از فروش مبنا برای پورسانت")
|
||||
commission_exclude_discounts: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه تخفیف در پورسانت")
|
||||
commission_exclude_additions_deductions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه اضافات و کسورات فاکتور در پورسانت")
|
||||
commission_post_in_invoice_document: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="ثبت پورسانت در سند حسابداری فاکتور")
|
||||
|
||||
# اطلاعات اقتصادی
|
||||
national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی")
|
||||
|
|
|
|||
37
hesabixAPI/adapters/db/repositories/fiscal_year_repo.py
Normal file
37
hesabixAPI/adapters/db/repositories/fiscal_year_repo.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .base_repo import BaseRepository
|
||||
from ..models.fiscal_year import FiscalYear
|
||||
|
||||
|
||||
class FiscalYearRepository(BaseRepository[FiscalYear]):
|
||||
"""Repository برای مدیریت سالهای مالی"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
super().__init__(db, FiscalYear)
|
||||
|
||||
def create_fiscal_year(
|
||||
self,
|
||||
*,
|
||||
business_id: int,
|
||||
title: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
is_last: bool = True,
|
||||
) -> FiscalYear:
|
||||
fiscal_year = FiscalYear(
|
||||
business_id=business_id,
|
||||
title=title,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
is_last=is_last,
|
||||
)
|
||||
self.db.add(fiscal_year)
|
||||
self.db.commit()
|
||||
self.db.refresh(fiscal_year)
|
||||
return fiscal_year
|
||||
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ from adapters.api.v1.users import router as users_router
|
|||
from adapters.api.v1.businesses import router as businesses_router
|
||||
from adapters.api.v1.business_dashboard import router as business_dashboard_router
|
||||
from adapters.api.v1.business_users import router as business_users_router
|
||||
from adapters.api.v1.accounts import router as accounts_router
|
||||
from adapters.api.v1.persons import router as persons_router
|
||||
from adapters.api.v1.support.tickets import router as support_tickets_router
|
||||
from adapters.api.v1.support.operator import router as support_operator_router
|
||||
|
|
@ -274,6 +275,7 @@ def create_app() -> FastAPI:
|
|||
application.include_router(businesses_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(business_users_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(accounts_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
# Support endpoints
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
|||
from sqlalchemy import select, and_, func
|
||||
|
||||
from adapters.db.repositories.business_repo import BusinessRepository
|
||||
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
|
||||
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
||||
from adapters.db.models.business import Business, BusinessType, BusinessField
|
||||
from adapters.api.v1.schemas import (
|
||||
|
|
@ -17,6 +18,7 @@ from app.core.responses import format_datetime_fields
|
|||
def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: int) -> Dict[str, Any]:
|
||||
"""ایجاد کسب و کار جدید"""
|
||||
business_repo = BusinessRepository(db)
|
||||
fiscal_repo = FiscalYearRepository(db)
|
||||
|
||||
# تبدیل enum values به مقادیر فارسی
|
||||
# business_data.business_type و business_data.business_field قبلاً مقادیر فارسی هستند
|
||||
|
|
@ -41,6 +43,22 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
|
|||
postal_code=business_data.postal_code
|
||||
)
|
||||
|
||||
# ایجاد سالهای مالی اولیه (در صورت ارسال)
|
||||
if getattr(business_data, "fiscal_years", None):
|
||||
# فقط یک سال با is_last=True نگه داریم (آخرین مورد True باشد)
|
||||
last_true_index = None
|
||||
for idx, fy in enumerate(business_data.fiscal_years or []):
|
||||
if fy.is_last:
|
||||
last_true_index = idx
|
||||
for idx, fy in enumerate(business_data.fiscal_years or []):
|
||||
fiscal_repo.create_fiscal_year(
|
||||
business_id=created_business.id,
|
||||
title=fy.title,
|
||||
start_date=fy.start_date,
|
||||
end_date=fy.end_date,
|
||||
is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1)
|
||||
)
|
||||
|
||||
# تبدیل به response format
|
||||
return _business_to_dict(created_business)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,39 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
|
|||
t = person_data.person_type
|
||||
types_list = [t.value if hasattr(t, 'value') else str(t)]
|
||||
|
||||
# اعتبارسنجی سهام برای سهامدار
|
||||
is_shareholder = False
|
||||
if types_list:
|
||||
is_shareholder = 'سهامدار' in types_list
|
||||
if not is_shareholder and incoming_single_type is not None:
|
||||
try:
|
||||
is_shareholder = (getattr(incoming_single_type, 'value', str(incoming_single_type)) == 'سهامدار')
|
||||
except Exception:
|
||||
is_shareholder = False
|
||||
if is_shareholder:
|
||||
sc_val = getattr(person_data, 'share_count', None)
|
||||
if sc_val is None or not isinstance(sc_val, int) or sc_val <= 0:
|
||||
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
||||
|
||||
# ایجاد شخص
|
||||
# نگاشت person_type دریافتی از اسکیما به Enum مدل
|
||||
incoming_single_type = getattr(person_data, 'person_type', None)
|
||||
mapped_single_type = None
|
||||
if incoming_single_type is not None:
|
||||
try:
|
||||
# incoming_single_type.value مقدار فارسی مانند "سهامدار"
|
||||
mapped_single_type = PersonType(getattr(incoming_single_type, 'value', str(incoming_single_type)))
|
||||
except Exception:
|
||||
mapped_single_type = None
|
||||
|
||||
person = Person(
|
||||
business_id=business_id,
|
||||
code=code,
|
||||
alias_name=person_data.alias_name,
|
||||
first_name=person_data.first_name,
|
||||
last_name=person_data.last_name,
|
||||
person_type=person_data.person_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER),
|
||||
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
|
||||
person_type=(mapped_single_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER)),
|
||||
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
|
||||
company_name=person_data.company_name,
|
||||
payment_id=person_data.payment_id,
|
||||
|
|
@ -58,6 +83,14 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
|
|||
fax=person_data.fax,
|
||||
email=person_data.email,
|
||||
website=person_data.website,
|
||||
share_count=getattr(person_data, 'share_count', None),
|
||||
commission_sale_percent=getattr(person_data, 'commission_sale_percent', None),
|
||||
commission_sales_return_percent=getattr(person_data, 'commission_sales_return_percent', None),
|
||||
commission_sales_amount=getattr(person_data, 'commission_sales_amount', None),
|
||||
commission_sales_return_amount=getattr(person_data, 'commission_sales_return_amount', None),
|
||||
commission_exclude_discounts=bool(getattr(person_data, 'commission_exclude_discounts', False)),
|
||||
commission_exclude_additions_deductions=bool(getattr(person_data, 'commission_exclude_additions_deductions', False)),
|
||||
commission_post_in_invoice_document=bool(getattr(person_data, 'commission_post_in_invoice_document', False)),
|
||||
)
|
||||
|
||||
db.add(person)
|
||||
|
|
@ -333,11 +366,37 @@ def update_person(
|
|||
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
|
||||
# همگام کردن person_type تکی برای سازگاری
|
||||
if types_list:
|
||||
# مقدار Enum را با مقدار فارسی ست میکنیم
|
||||
try:
|
||||
person.person_type = PersonType(types_list[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# مدیریت person_type تکی از اسکیما
|
||||
if 'person_type' in update_data and update_data['person_type'] is not None:
|
||||
single_type = update_data['person_type']
|
||||
# نگاشت به Enum (مقدار فارسی)
|
||||
try:
|
||||
person.person_type = PersonType(getattr(single_type, 'value', str(single_type)))
|
||||
except Exception:
|
||||
pass
|
||||
# پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود
|
||||
update_data.pop('person_type', None)
|
||||
|
||||
# اگر شخص سهامدار شد، share_count معتبر باشد
|
||||
resulting_types: List[str] = []
|
||||
if person.person_types:
|
||||
try:
|
||||
tmp = json.loads(person.person_types)
|
||||
if isinstance(tmp, list):
|
||||
resulting_types = [str(x) for x in tmp]
|
||||
except Exception:
|
||||
resulting_types = []
|
||||
if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
|
||||
sc_val2 = update_data.get('share_count', person.share_count)
|
||||
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
||||
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
||||
|
||||
# سایر فیلدها
|
||||
for field in list(update_data.keys()):
|
||||
if field in {'code', 'person_types'}:
|
||||
|
|
@ -416,6 +475,14 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
|||
'person_types': types_list,
|
||||
'company_name': person.company_name,
|
||||
'payment_id': person.payment_id,
|
||||
'share_count': person.share_count,
|
||||
'commission_sale_percent': float(person.commission_sale_percent) if getattr(person, 'commission_sale_percent', None) is not None else None,
|
||||
'commission_sales_return_percent': float(person.commission_sales_return_percent) if getattr(person, 'commission_sales_return_percent', None) is not None else None,
|
||||
'commission_sales_amount': float(person.commission_sales_amount) if getattr(person, 'commission_sales_amount', None) is not None else None,
|
||||
'commission_sales_return_amount': float(person.commission_sales_return_amount) if getattr(person, 'commission_sales_return_amount', None) is not None else None,
|
||||
'commission_exclude_discounts': bool(person.commission_exclude_discounts),
|
||||
'commission_exclude_additions_deductions': bool(person.commission_exclude_additions_deductions),
|
||||
'commission_post_in_invoice_document': bool(person.commission_post_in_invoice_document),
|
||||
'national_id': person.national_id,
|
||||
'registration_number': person.registration_number,
|
||||
'economic_id': person.economic_id,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pyproject.toml
|
|||
adapters/__init__.py
|
||||
adapters/api/__init__.py
|
||||
adapters/api/v1/__init__.py
|
||||
adapters/api/v1/accounts.py
|
||||
adapters/api/v1/auth.py
|
||||
adapters/api/v1/business_dashboard.py
|
||||
adapters/api/v1/business_users.py
|
||||
|
|
@ -14,6 +15,7 @@ adapters/api/v1/users.py
|
|||
adapters/api/v1/admin/email_config.py
|
||||
adapters/api/v1/admin/file_storage.py
|
||||
adapters/api/v1/schema_models/__init__.py
|
||||
adapters/api/v1/schema_models/account.py
|
||||
adapters/api/v1/schema_models/email.py
|
||||
adapters/api/v1/schema_models/file_storage.py
|
||||
adapters/api/v1/schema_models/person.py
|
||||
|
|
@ -27,12 +29,17 @@ adapters/api/v1/support/tickets.py
|
|||
adapters/db/__init__.py
|
||||
adapters/db/session.py
|
||||
adapters/db/models/__init__.py
|
||||
adapters/db/models/account.py
|
||||
adapters/db/models/api_key.py
|
||||
adapters/db/models/business.py
|
||||
adapters/db/models/business_permission.py
|
||||
adapters/db/models/captcha.py
|
||||
adapters/db/models/currency.py
|
||||
adapters/db/models/document.py
|
||||
adapters/db/models/document_line.py
|
||||
adapters/db/models/email_config.py
|
||||
adapters/db/models/file_storage.py
|
||||
adapters/db/models/fiscal_year.py
|
||||
adapters/db/models/password_reset.py
|
||||
adapters/db/models/person.py
|
||||
adapters/db/models/user.py
|
||||
|
|
@ -48,6 +55,7 @@ adapters/db/repositories/business_permission_repo.py
|
|||
adapters/db/repositories/business_repo.py
|
||||
adapters/db/repositories/email_config_repository.py
|
||||
adapters/db/repositories/file_storage_repository.py
|
||||
adapters/db/repositories/fiscal_year_repo.py
|
||||
adapters/db/repositories/password_reset_repo.py
|
||||
adapters/db/repositories/user_repo.py
|
||||
adapters/db/repositories/support/__init__.py
|
||||
|
|
@ -104,7 +112,20 @@ migrations/versions/20250915_000001_init_auth_tables.py
|
|||
migrations/versions/20250916_000002_add_referral_fields.py
|
||||
migrations/versions/20250926_000010_add_person_code_and_types.py
|
||||
migrations/versions/20250926_000011_drop_person_is_active.py
|
||||
migrations/versions/20250927_000012_add_fiscal_years_table.py
|
||||
migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
|
||||
migrations/versions/20250927_000014_add_documents_table.py
|
||||
migrations/versions/20250927_000015_add_document_lines_table.py
|
||||
migrations/versions/20250927_000016_add_accounts_table.py
|
||||
migrations/versions/20250927_000017_add_account_id_to_document_lines.py
|
||||
migrations/versions/20250927_000018_seed_currencies.py
|
||||
migrations/versions/20250927_000019_seed_accounts_chart.py
|
||||
migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
|
||||
migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
|
||||
migrations/versions/20250927_000022_add_person_commission_fields.py
|
||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
||||
migrations/versions/f876bfa36805_merge_multiple_heads.py
|
||||
tests/__init__.py
|
||||
tests/test_health.py
|
||||
tests/test_permissions.py
|
||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
|
@ -12,7 +13,11 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create businesses table
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Create businesses table if not exists
|
||||
if 'businesses' not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
'businesses',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
|
|
@ -26,8 +31,11 @@ def upgrade() -> None:
|
|||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
# Create indexes if not exists
|
||||
existing_indexes = {idx['name'] for idx in inspector.get_indexes('businesses')} if 'businesses' in inspector.get_table_names() else set()
|
||||
if 'ix_businesses_name' not in existing_indexes:
|
||||
op.create_index('ix_businesses_name', 'businesses', ['name'])
|
||||
if 'ix_businesses_owner_id' not in existing_indexes:
|
||||
op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id'])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250926_000010_add_person_code_and_types'
|
||||
|
|
@ -9,9 +10,17 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
|
||||
with op.batch_alter_table('persons') as batch_op:
|
||||
if 'code' not in cols:
|
||||
batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
|
||||
if 'person_types' not in cols:
|
||||
batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True))
|
||||
# unique constraint if not exists
|
||||
existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('persons')}
|
||||
if 'uq_persons_business_code' not in existing_uniques:
|
||||
batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code'])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000012_add_fiscal_years_table'
|
||||
down_revision = '20250926_000011_drop_person_is_active'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Create fiscal_years table if not exists
|
||||
if 'fiscal_years' not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
'fiscal_years',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('start_date', sa.Date(), nullable=False),
|
||||
sa.Column('end_date', sa.Date(), nullable=False),
|
||||
sa.Column('is_last', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Indexes if not exists
|
||||
existing_indexes = {idx['name'] for idx in inspector.get_indexes('fiscal_years')} if 'fiscal_years' in inspector.get_table_names() else set()
|
||||
if 'ix_fiscal_years_business_id' not in existing_indexes:
|
||||
op.create_index('ix_fiscal_years_business_id', 'fiscal_years', ['business_id'])
|
||||
if 'ix_fiscal_years_title' not in existing_indexes:
|
||||
op.create_index('ix_fiscal_years_title', 'fiscal_years', ['title'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_fiscal_years_title', table_name='fiscal_years')
|
||||
op.drop_index('ix_fiscal_years_business_id', table_name='fiscal_years')
|
||||
op.drop_table('fiscal_years')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
down_revision = '20250927_000012_add_fiscal_years_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create currencies table
|
||||
op.create_table(
|
||||
'currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||
sa.Column('code', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique constraints and indexes
|
||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||
|
||||
# Create business_currencies association table
|
||||
op.create_table(
|
||||
'business_currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique and indexes for association
|
||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||
op.drop_table('business_currencies')
|
||||
|
||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||
op.drop_table('currencies')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000014_add_documents_table'
|
||||
down_revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create documents table
|
||||
op.create_table(
|
||||
'documents',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('code', sa.String(length=50), nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_by_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('registered_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('document_date', sa.Date(), nullable=False),
|
||||
sa.Column('document_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_settings', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
# Unique per business code
|
||||
op.create_unique_constraint('uq_documents_business_code', 'documents', ['business_id', 'code'])
|
||||
|
||||
# Indexes
|
||||
op.create_index('ix_documents_code', 'documents', ['code'])
|
||||
op.create_index('ix_documents_business_id', 'documents', ['business_id'])
|
||||
op.create_index('ix_documents_currency_id', 'documents', ['currency_id'])
|
||||
op.create_index('ix_documents_created_by_user_id', 'documents', ['created_by_user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_documents_created_by_user_id', table_name='documents')
|
||||
op.drop_index('ix_documents_currency_id', table_name='documents')
|
||||
op.drop_index('ix_documents_business_id', table_name='documents')
|
||||
op.drop_index('ix_documents_code', table_name='documents')
|
||||
op.drop_constraint('uq_documents_business_code', 'documents', type_='unique')
|
||||
op.drop_table('documents')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000015_add_document_lines_table'
|
||||
down_revision = '20250927_000014_add_documents_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'document_lines',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||
op.drop_table('document_lines')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000016_add_accounts_table'
|
||||
down_revision = '20250927_000015_add_document_lines_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'accounts',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=True),
|
||||
sa.Column('account_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('code', sa.String(length=50), nullable=False),
|
||||
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['accounts.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
op.create_unique_constraint('uq_accounts_business_code', 'accounts', ['business_id', 'code'])
|
||||
op.create_index('ix_accounts_name', 'accounts', ['name'])
|
||||
op.create_index('ix_accounts_business_id', 'accounts', ['business_id'])
|
||||
op.create_index('ix_accounts_parent_id', 'accounts', ['parent_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_accounts_parent_id', table_name='accounts')
|
||||
op.drop_index('ix_accounts_business_id', table_name='accounts')
|
||||
op.drop_index('ix_accounts_name', table_name='accounts')
|
||||
op.drop_constraint('uq_accounts_business_code', 'accounts', type_='unique')
|
||||
op.drop_table('accounts')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000017_add_account_id_to_document_lines'
|
||||
down_revision = '20250927_000016_add_accounts_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('document_lines') as batch_op:
|
||||
batch_op.add_column(sa.Column('account_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_document_lines_account_id_accounts', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT')
|
||||
batch_op.create_index('ix_document_lines_account_id', ['account_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('document_lines') as batch_op:
|
||||
batch_op.drop_index('ix_document_lines_account_id')
|
||||
batch_op.drop_constraint('fk_document_lines_account_id_accounts', type_='foreignkey')
|
||||
batch_op.drop_column('account_id')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000018_seed_currencies'
|
||||
down_revision = 'f876bfa36805'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
insert_sql = sa.text(
|
||||
"""
|
||||
INSERT INTO currencies (name, title, symbol, code, created_at, updated_at)
|
||||
VALUES (:name, :title, :symbol, :code, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
symbol = VALUES(symbol),
|
||||
updated_at = VALUES(updated_at)
|
||||
"""
|
||||
)
|
||||
|
||||
currencies = [
|
||||
{"name": "Iranian Rial", "title": "ریال ایران", "symbol": "﷼", "code": "IRR"},
|
||||
{"name": "United States Dollar", "title": "US Dollar", "symbol": "$", "code": "USD"},
|
||||
{"name": "Euro", "title": "Euro", "symbol": "€", "code": "EUR"},
|
||||
{"name": "British Pound", "title": "Pound Sterling", "symbol": "£", "code": "GBP"},
|
||||
{"name": "Japanese Yen", "title": "Yen", "symbol": "¥", "code": "JPY"},
|
||||
{"name": "Chinese Yuan", "title": "Yuan", "symbol": "¥", "code": "CNY"},
|
||||
{"name": "Swiss Franc", "title": "Swiss Franc", "symbol": "CHF", "code": "CHF"},
|
||||
{"name": "Canadian Dollar", "title": "Canadian Dollar", "symbol": "$", "code": "CAD"},
|
||||
{"name": "Australian Dollar", "title": "Australian Dollar", "symbol": "$", "code": "AUD"},
|
||||
{"name": "New Zealand Dollar", "title": "New Zealand Dollar", "symbol": "$", "code": "NZD"},
|
||||
{"name": "Russian Ruble", "title": "Ruble", "symbol": "₽", "code": "RUB"},
|
||||
{"name": "Turkish Lira", "title": "Lira", "symbol": "₺", "code": "TRY"},
|
||||
{"name": "UAE Dirham", "title": "Dirham", "symbol": "د.إ", "code": "AED"},
|
||||
{"name": "Saudi Riyal", "title": "Riyal", "symbol": "﷼", "code": "SAR"},
|
||||
{"name": "Qatari Riyal", "title": "Qatari Riyal", "symbol": "﷼", "code": "QAR"},
|
||||
{"name": "Kuwaiti Dinar", "title": "Kuwaiti Dinar", "symbol": "د.ك", "code": "KWD"},
|
||||
{"name": "Omani Rial", "title": "Omani Rial", "symbol": "﷼", "code": "OMR"},
|
||||
{"name": "Bahraini Dinar", "title": "Bahraini Dinar", "symbol": ".د.ب", "code": "BHD"},
|
||||
{"name": "Iraqi Dinar", "title": "Iraqi Dinar", "symbol": "ع.د", "code": "IQD"},
|
||||
{"name": "Afghan Afghani", "title": "Afghani", "symbol": "؋", "code": "AFN"},
|
||||
{"name": "Pakistani Rupee", "title": "Rupee", "symbol": "₨", "code": "PKR"},
|
||||
{"name": "Indian Rupee", "title": "Rupee", "symbol": "₹", "code": "INR"},
|
||||
{"name": "Armenian Dram", "title": "Dram", "symbol": "֏", "code": "AMD"},
|
||||
{"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
|
||||
{"name": "Georgian Lari", "title": "Lari", "symbol": "₾", "code": "GEL"},
|
||||
{"name": "Kazakhstani Tenge", "title": "Tenge", "symbol": "₸", "code": "KZT"},
|
||||
{"name": "Uzbekistani Som", "title": "Som", "symbol": "so'm", "code": "UZS"},
|
||||
{"name": "Tajikistani Somoni", "title": "Somoni", "symbol": "ЅМ", "code": "TJS"},
|
||||
{"name": "Turkmenistani Manat", "title": "Manat", "symbol": "m", "code": "TMT"},
|
||||
{"name": "Afgani Lek", "title": "Lek", "symbol": "L", "code": "ALL"},
|
||||
{"name": "Bulgarian Lev", "title": "Lev", "symbol": "лв", "code": "BGN"},
|
||||
{"name": "Romanian Leu", "title": "Leu", "symbol": "lei", "code": "RON"},
|
||||
{"name": "Polish Złoty", "title": "Zloty", "symbol": "zł", "code": "PLN"},
|
||||
{"name": "Czech Koruna", "title": "Koruna", "symbol": "Kč", "code": "CZK"},
|
||||
{"name": "Hungarian Forint", "title": "Forint", "symbol": "Ft", "code": "HUF"},
|
||||
{"name": "Danish Krone", "title": "Krone", "symbol": "kr", "code": "DKK"},
|
||||
{"name": "Norwegian Krone", "title": "Krone", "symbol": "kr", "code": "NOK"},
|
||||
{"name": "Swedish Krona", "title": "Krona", "symbol": "kr", "code": "SEK"},
|
||||
{"name": "Icelandic Króna", "title": "Krona", "symbol": "kr", "code": "ISK"},
|
||||
{"name": "Croatian Kuna", "title": "Kuna", "symbol": "kn", "code": "HRK"},
|
||||
{"name": "Serbian Dinar", "title": "Dinar", "symbol": "дин.", "code": "RSD"},
|
||||
{"name": "Bosnia and Herzegovina Mark", "title": "Mark", "symbol": "KM", "code": "BAM"},
|
||||
{"name": "Ukrainian Hryvnia", "title": "Hryvnia", "symbol": "₴", "code": "UAH"},
|
||||
{"name": "Belarusian Ruble", "title": "Ruble", "symbol": "Br", "code": "BYN"},
|
||||
{"name": "Egyptian Pound", "title": "Pound", "symbol": "£", "code": "EGP"},
|
||||
{"name": "South African Rand", "title": "Rand", "symbol": "R", "code": "ZAR"},
|
||||
{"name": "Nigerian Naira", "title": "Naira", "symbol": "₦", "code": "NGN"},
|
||||
{"name": "Kenyan Shilling", "title": "Shilling", "symbol": "Sh", "code": "KES"},
|
||||
{"name": "Ethiopian Birr", "title": "Birr", "symbol": "Br", "code": "ETB"},
|
||||
{"name": "Moroccan Dirham", "title": "Dirham", "symbol": "د.م.", "code": "MAD"},
|
||||
{"name": "Tunisian Dinar", "title": "Dinar", "symbol": "د.ت", "code": "TND"},
|
||||
{"name": "Algerian Dinar", "title": "Dinar", "symbol": "د.ج", "code": "DZD"},
|
||||
{"name": "Israeli New Shekel", "title": "Shekel", "symbol": "₪", "code": "ILS"},
|
||||
{"name": "Jordanian Dinar", "title": "Dinar", "symbol": "د.ا", "code": "JOD"},
|
||||
{"name": "Lebanese Pound", "title": "Pound", "symbol": "ل.ل", "code": "LBP"},
|
||||
{"name": "Syrian Pound", "title": "Pound", "symbol": "£", "code": "SYP"},
|
||||
{"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
|
||||
{"name": "Singapore Dollar", "title": "Singapore Dollar", "symbol": "$", "code": "SGD"},
|
||||
{"name": "Hong Kong Dollar", "title": "Hong Kong Dollar", "symbol": "$", "code": "HKD"},
|
||||
{"name": "Thai Baht", "title": "Baht", "symbol": "฿", "code": "THB"},
|
||||
{"name": "Malaysian Ringgit", "title": "Ringgit", "symbol": "RM", "code": "MYR"},
|
||||
{"name": "Indonesian Rupiah", "title": "Rupiah", "symbol": "Rp", "code": "IDR"},
|
||||
{"name": "Philippine Peso", "title": "Peso", "symbol": "₱", "code": "PHP"},
|
||||
{"name": "Vietnamese Dong", "title": "Dong", "symbol": "₫", "code": "VND"},
|
||||
{"name": "South Korean Won", "title": "Won", "symbol": "₩", "code": "KRW"},
|
||||
{"name": "Taiwan New Dollar", "title": "New Dollar", "symbol": "$", "code": "TWD"},
|
||||
{"name": "Mexican Peso", "title": "Peso", "symbol": "$", "code": "MXN"},
|
||||
{"name": "Brazilian Real", "title": "Real", "symbol": "R$", "code": "BRL"},
|
||||
{"name": "Argentine Peso", "title": "Peso", "symbol": "$", "code": "ARS"},
|
||||
{"name": "Chilean Peso", "title": "Peso", "symbol": "$", "code": "CLP"},
|
||||
{"name": "Colombian Peso", "title": "Peso", "symbol": "$", "code": "COP"},
|
||||
{"name": "Peruvian Sol", "title": "Sol", "symbol": "S/.", "code": "PEN"},
|
||||
{"name": "Uruguayan Peso", "title": "Peso", "symbol": "$U", "code": "UYU"},
|
||||
{"name": "Paraguayan Guarani", "title": "Guarani", "symbol": "₲", "code": "PYG"},
|
||||
{"name": "Bolivian Boliviano", "title": "Boliviano", "symbol": "Bs.", "code": "BOB"},
|
||||
{"name": "Dominican Peso", "title": "Peso", "symbol": "RD$", "code": "DOP"},
|
||||
{"name": "Cuban Peso", "title": "Peso", "symbol": "$", "code": "CUP"},
|
||||
{"name": "Costa Rican Colon", "title": "Colon", "symbol": "₡", "code": "CRC"},
|
||||
{"name": "Guatemalan Quetzal", "title": "Quetzal", "symbol": "Q", "code": "GTQ"},
|
||||
{"name": "Honduran Lempira", "title": "Lempira", "symbol": "L", "code": "HNL"},
|
||||
{"name": "Nicaraguan Córdoba", "title": "Cordoba", "symbol": "C$", "code": "NIO"},
|
||||
{"name": "Panamanian Balboa", "title": "Balboa", "symbol": "B/.", "code": "PAB"},
|
||||
{"name": "Venezuelan Bolívar", "title": "Bolivar", "symbol": "Bs.", "code": "VES"},
|
||||
]
|
||||
|
||||
for row in currencies:
|
||||
conn.execute(insert_sql, row)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
codes = [
|
||||
'IRR','USD','EUR','GBP','JPY','CNY','CHF','CAD','AUD','NZD','RUB','TRY','AED','SAR','QAR','KWD','OMR','BHD','IQD','AFN','PKR','INR','AMD','AZN','GEL','KZT','UZS','TJS','TMT','ALL','BGN','RON','PLN','CZK','HUF','DKK','NOK','SEK','ISK','HRK','RSD','BAM','UAH','BYN','EGP','ZAR','NGN','KES','ETB','MAD','TND','DZD','ILS','JOD','LBP','SYP','SGD','HKD','THB','MYR','IDR','PHP','VND','KRW','TWD','MXN','BRL','ARS','CLP','COP','PEN','UYU','PYG','BOB','DOP','CUP','CRC','GTQ','HNL','NIO','PAB','VES'
|
||||
]
|
||||
delete_sql = sa.text("DELETE FROM currencies WHERE code IN :codes")
|
||||
conn.execute(delete_sql, {"codes": tuple(codes)})
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000019_seed_accounts_chart'
|
||||
down_revision = '20250927_000018_seed_currencies'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# دادهها (خلاصهشده برای خوانایی؛ از JSON کاربر)
|
||||
accounts = [
|
||||
{"id":2452,"level":1,"code":"1","name":"دارایی ها","parentId":0,"accountType":0},
|
||||
{"id":2453,"level":2,"code":"101","name":"دارایی های جاری","parentId":2452,"accountType":0},
|
||||
{"id":2454,"level":3,"code":"102","name":"موجودی نقد و بانک","parentId":2453,"accountType":0},
|
||||
{"id":2455,"level":4,"code":"10201","name":"تنخواه گردان","parentId":2454,"accountType":2},
|
||||
{"id":2456,"level":4,"code":"10202","name":"صندوق","parentId":2454,"accountType":1},
|
||||
{"id":2457,"level":4,"code":"10203","name":"بانک","parentId":2454,"accountType":3},
|
||||
{"id":2458,"level":4,"code":"10204","name":"وجوه در راه","parentId":2454,"accountType":0},
|
||||
{"id":2459,"level":3,"code":"103","name":"سپرده های کوتاه مدت","parentId":2453,"accountType":0},
|
||||
{"id":2460,"level":4,"code":"10301","name":"سپرده شرکت در مناقصه و مزایده","parentId":2459,"accountType":0},
|
||||
{"id":2461,"level":4,"code":"10302","name":"ضمانت نامه بانکی","parentId":2459,"accountType":0},
|
||||
{"id":2462,"level":4,"code":"10303","name":"سایر سپرده ها","parentId":2459,"accountType":0},
|
||||
{"id":2463,"level":3,"code":"104","name":"حساب های دریافتنی","parentId":2453,"accountType":0},
|
||||
{"id":2464,"level":4,"code":"10401","name":"حساب های دریافتنی","parentId":2463,"accountType":4},
|
||||
{"id":2465,"level":4,"code":"10402","name":"ذخیره مطالبات مشکوک الوصول","parentId":2463,"accountType":0},
|
||||
{"id":2466,"level":4,"code":"10403","name":"اسناد دریافتنی","parentId":2463,"accountType":5},
|
||||
{"id":2467,"level":4,"code":"10404","name":"اسناد در جریان وصول","parentId":2463,"accountType":6},
|
||||
{"id":2468,"level":3,"code":"105","name":"سایر حساب های دریافتنی","parentId":2453,"accountType":0},
|
||||
{"id":2469,"level":4,"code":"10501","name":"وام کارکنان","parentId":2468,"accountType":0},
|
||||
{"id":2470,"level":4,"code":"10502","name":"سایر حساب های دریافتنی","parentId":2468,"accountType":0},
|
||||
{"id":2471,"level":3,"code":"10101","name":"پیش پرداخت ها","parentId":2453,"accountType":0},
|
||||
{"id":2472,"level":3,"code":"10102","name":"موجودی کالا","parentId":2453,"accountType":7},
|
||||
{"id":2473,"level":3,"code":"10103","name":"ملزومات","parentId":2453,"accountType":0},
|
||||
{"id":2474,"level":3,"code":"10104","name":"مالیات بر ارزش افزوده خرید","parentId":2453,"accountType":8},
|
||||
{"id":2475,"level":2,"code":"106","name":"دارایی های غیر جاری","parentId":2452,"accountType":0},
|
||||
{"id":2476,"level":3,"code":"107","name":"دارایی های ثابت","parentId":2475,"accountType":0},
|
||||
{"id":2477,"level":4,"code":"10701","name":"زمین","parentId":2476,"accountType":0},
|
||||
{"id":2478,"level":4,"code":"10702","name":"ساختمان","parentId":2476,"accountType":0},
|
||||
{"id":2479,"level":4,"code":"10703","name":"وسائط نقلیه","parentId":2476,"accountType":0},
|
||||
{"id":2480,"level":4,"code":"10704","name":"اثاثیه اداری","parentId":2476,"accountType":0},
|
||||
{"id":2481,"level":3,"code":"108","name":"استهلاک انباشته","parentId":2475,"accountType":0},
|
||||
{"id":2482,"level":4,"code":"10801","name":"استهلاک انباشته ساختمان","parentId":2481,"accountType":0},
|
||||
{"id":2483,"level":4,"code":"10802","name":"استهلاک انباشته وسائط نقلیه","parentId":2481,"accountType":0},
|
||||
{"id":2484,"level":4,"code":"10803","name":"استهلاک انباشته اثاثیه اداری","parentId":2481,"accountType":0},
|
||||
{"id":2485,"level":3,"code":"109","name":"سپرده های بلندمدت","parentId":2475,"accountType":0},
|
||||
{"id":2486,"level":3,"code":"110","name":"سایر دارائی ها","parentId":2475,"accountType":0},
|
||||
{"id":2487,"level":4,"code":"11001","name":"حق الامتیازها","parentId":2486,"accountType":0},
|
||||
{"id":2488,"level":4,"code":"11002","name":"نرم افزارها","parentId":2486,"accountType":0},
|
||||
{"id":2489,"level":4,"code":"11003","name":"سایر دارایی های نامشهود","parentId":2486,"accountType":0},
|
||||
{"id":2490,"level":1,"code":"2","name":"بدهی ها","parentId":0,"accountType":0},
|
||||
{"id":2491,"level":2,"code":"201","name":"بدهیهای جاری","parentId":2490,"accountType":0},
|
||||
{"id":2492,"level":3,"code":"202","name":"حساب ها و اسناد پرداختنی","parentId":2491,"accountType":0},
|
||||
{"id":2493,"level":4,"code":"20201","name":"حساب های پرداختنی","parentId":2492,"accountType":9},
|
||||
{"id":2494,"level":4,"code":"20202","name":"اسناد پرداختنی","parentId":2492,"accountType":10},
|
||||
{"id":2495,"level":3,"code":"203","name":"سایر حساب های پرداختنی","parentId":2491,"accountType":0},
|
||||
{"id":2496,"level":4,"code":"20301","name":"ذخیره مالیات بر درآمد پرداختنی","parentId":2495,"accountType":40},
|
||||
{"id":2497,"level":4,"code":"20302","name":"مالیات بر درآمد پرداختنی","parentId":2495,"accountType":12},
|
||||
{"id":2498,"level":4,"code":"20303","name":"مالیات حقوق و دستمزد پرداختنی","parentId":2495,"accountType":0},
|
||||
{"id":2499,"level":4,"code":"20304","name":"حق بیمه پرداختنی","parentId":2495,"accountType":0},
|
||||
{"id":2500,"level":4,"code":"20305","name":"حقوق و دستمزد پرداختنی","parentId":2495,"accountType":42},
|
||||
{"id":2501,"level":4,"code":"20306","name":"عیدی و پاداش پرداختنی","parentId":2495,"accountType":0},
|
||||
{"id":2502,"level":4,"code":"20307","name":"سایر هزینه های پرداختنی","parentId":2495,"accountType":0},
|
||||
{"id":2503,"level":3,"code":"204","name":"پیش دریافت ها","parentId":2491,"accountType":0},
|
||||
{"id":2504,"level":4,"code":"20401","name":"پیش دریافت فروش","parentId":2503,"accountType":0},
|
||||
{"id":2505,"level":4,"code":"20402","name":"سایر پیش دریافت ها","parentId":2503,"accountType":0},
|
||||
{"id":2506,"level":3,"code":"20101","name":"مالیات بر ارزش افزوده فروش","parentId":2491,"accountType":11},
|
||||
{"id":2507,"level":2,"code":"205","name":"بدهیهای غیر جاری","parentId":2490,"accountType":0},
|
||||
{"id":2508,"level":3,"code":"206","name":"حساب ها و اسناد پرداختنی بلندمدت","parentId":2507,"accountType":0},
|
||||
{"id":2509,"level":4,"code":"20601","name":"حساب های پرداختنی بلندمدت","parentId":2508,"accountType":0},
|
||||
{"id":2510,"level":4,"code":"20602","name":"اسناد پرداختنی بلندمدت","parentId":2508,"accountType":0},
|
||||
{"id":2511,"level":3,"code":"20501","name":"وام پرداختنی","parentId":2507,"accountType":0},
|
||||
{"id":2512,"level":3,"code":"20502","name":"ذخیره مزایای پایان خدمت کارکنان","parentId":2507,"accountType":0},
|
||||
{"id":2513,"level":1,"code":"3","name":"حقوق صاحبان سهام","parentId":0,"accountType":0},
|
||||
{"id":2514,"level":2,"code":"301","name":"سرمایه","parentId":2513,"accountType":0},
|
||||
{"id":2515,"level":3,"code":"30101","name":"سرمایه اولیه","parentId":2514,"accountType":13},
|
||||
{"id":2516,"level":3,"code":"30102","name":"افزایش یا کاهش سرمایه","parentId":2514,"accountType":14},
|
||||
{"id":2517,"level":3,"code":"30103","name":"اندوخته قانونی","parentId":2514,"accountType":15},
|
||||
{"id":2518,"level":3,"code":"30104","name":"برداشت ها","parentId":2514,"accountType":16},
|
||||
{"id":2519,"level":3,"code":"30105","name":"سهم سود و زیان","parentId":2514,"accountType":17},
|
||||
{"id":2520,"level":3,"code":"30106","name":"سود یا زیان انباشته (سنواتی)","parentId":2514,"accountType":18},
|
||||
{"id":2521,"level":1,"code":"4","name":"بهای تمام شده کالای فروخته شده","parentId":0,"accountType":0},
|
||||
{"id":2522,"level":2,"code":"40001","name":"بهای تمام شده کالای فروخته شده","parentId":2521,"accountType":19},
|
||||
{"id":2523,"level":2,"code":"40002","name":"برگشت از خرید","parentId":2521,"accountType":20},
|
||||
{"id":2524,"level":2,"code":"40003","name":"تخفیفات نقدی خرید","parentId":2521,"accountType":21},
|
||||
{"id":2525,"level":1,"code":"5","name":"فروش","parentId":0,"accountType":0},
|
||||
{"id":2526,"level":2,"code":"50001","name":"فروش کالا","parentId":2525,"accountType":22},
|
||||
{"id":2527,"level":2,"code":"50002","name":"برگشت از فروش","parentId":2525,"accountType":23},
|
||||
{"id":2528,"level":2,"code":"50003","name":"تخفیفات نقدی فروش","parentId":2525,"accountType":24},
|
||||
{"id":2529,"level":1,"code":"6","name":"درآمد","parentId":0,"accountType":0},
|
||||
{"id":2530,"level":2,"code":"601","name":"درآمد های عملیاتی","parentId":2529,"accountType":0},
|
||||
{"id":2531,"level":3,"code":"60101","name":"درآمد حاصل از فروش خدمات","parentId":2530,"accountType":25},
|
||||
{"id":2532,"level":3,"code":"60102","name":"برگشت از خرید خدمات","parentId":2530,"accountType":26},
|
||||
{"id":2533,"level":3,"code":"60103","name":"درآمد اضافه کالا","parentId":2530,"accountType":27},
|
||||
{"id":2534,"level":3,"code":"60104","name":"درآمد حمل کالا","parentId":2530,"accountType":28},
|
||||
{"id":2535,"level":2,"code":"602","name":"درآمد های غیر عملیاتی","parentId":2529,"accountType":0},
|
||||
{"id":2536,"level":3,"code":"60201","name":"درآمد حاصل از سرمایه گذاری","parentId":2535,"accountType":0},
|
||||
{"id":2537,"level":3,"code":"60202","name":"درآمد سود سپرده ها","parentId":2535,"accountType":0},
|
||||
{"id":2538,"level":3,"code":"60203","name":"سایر درآمد ها","parentId":2535,"accountType":0},
|
||||
{"id":2539,"level":3,"code":"60204","name":"درآمد تسعیر ارز","parentId":2535,"accountType":36},
|
||||
{"id":2540,"level":1,"code":"7","name":"هزینه ها","parentId":0,"accountType":0},
|
||||
{"id":2541,"level":2,"code":"701","name":"هزینه های پرسنلی","parentId":2540,"accountType":0},
|
||||
{"id":2542,"level":3,"code":"702","name":"هزینه حقوق و دستمزد","parentId":2541,"accountType":0},
|
||||
{"id":2543,"level":4,"code":"70201","name":"حقوق پایه","parentId":2542,"accountType":0},
|
||||
{"id":2544,"level":4,"code":"70202","name":"اضافه کار","parentId":2542,"accountType":0},
|
||||
{"id":2545,"level":4,"code":"70203","name":"حق شیفت و شب کاری","parentId":2542,"accountType":0},
|
||||
{"id":2546,"level":4,"code":"70204","name":"حق نوبت کاری","parentId":2542,"accountType":0},
|
||||
{"id":2547,"level":4,"code":"70205","name":"حق ماموریت","parentId":2542,"accountType":0},
|
||||
{"id":2548,"level":4,"code":"70206","name":"فوق العاده مسکن و خاروبار","parentId":2542,"accountType":0},
|
||||
{"id":2549,"level":4,"code":"70207","name":"حق اولاد","parentId":2542,"accountType":0},
|
||||
{"id":2550,"level":4,"code":"70208","name":"عیدی و پاداش","parentId":2542,"accountType":0},
|
||||
{"id":2551,"level":4,"code":"70209","name":"بازخرید سنوات خدمت کارکنان","parentId":2542,"accountType":0},
|
||||
{"id":2552,"level":4,"code":"70210","name":"بازخرید مرخصی","parentId":2542,"accountType":0},
|
||||
{"id":2553,"level":4,"code":"70211","name":"بیمه سهم کارفرما","parentId":2542,"accountType":0},
|
||||
{"id":2554,"level":4,"code":"70212","name":"بیمه بیکاری","parentId":2542,"accountType":0},
|
||||
{"id":2555,"level":4,"code":"70213","name":"حقوق مزایای متفرقه","parentId":2542,"accountType":0},
|
||||
{"id":2556,"level":3,"code":"703","name":"سایر هزینه های کارکنان","parentId":2541,"accountType":0},
|
||||
{"id":2557,"level":4,"code":"70301","name":"سفر و ماموریت","parentId":2556,"accountType":0},
|
||||
{"id":2558,"level":4,"code":"70302","name":"ایاب و ذهاب","parentId":2556,"accountType":0},
|
||||
{"id":2559,"level":4,"code":"70303","name":"سایر هزینه های کارکنان","parentId":2556,"accountType":0},
|
||||
{"id":2560,"level":2,"code":"704","name":"هزینه های عملیاتی","parentId":2540,"accountType":0},
|
||||
{"id":2561,"level":3,"code":"70401","name":"خرید خدمات","parentId":2560,"accountType":30},
|
||||
{"id":2562,"level":3,"code":"70402","name":"برگشت از فروش خدمات","parentId":2560,"accountType":29},
|
||||
{"id":2563,"level":3,"code":"70403","name":"هزینه حمل کالا","parentId":2560,"accountType":31},
|
||||
{"id":2564,"level":3,"code":"70404","name":"تعمیر و نگهداری اموال و اثاثیه","parentId":2560,"accountType":0},
|
||||
{"id":2565,"level":3,"code":"70405","name":"هزینه اجاره محل","parentId":2560,"accountType":0},
|
||||
{"id":2566,"level":2,"code":"705","name":"هزینه های عمومی","parentId":2540,"accountType":0},
|
||||
{"id":2567,"level":4,"code":"70501","name":"هزینه آب و برق و گاز و تلفن","parentId":2566,"accountType":0},
|
||||
{"id":2568,"level":4,"code":"70502","name":"هزینه پذیرایی و آبدارخانه","parentId":2566,"accountType":0},
|
||||
{"id":2569,"level":3,"code":"70406","name":"هزینه ملزومات مصرفی","parentId":2560,"accountType":0},
|
||||
{"id":2570,"level":3,"code":"70407","name":"هزینه کسری و ضایعات کالا","parentId":2560,"accountType":32},
|
||||
{"id":2571,"level":3,"code":"70408","name":"بیمه دارایی های ثابت","parentId":2560,"accountType":0},
|
||||
{"id":2572,"level":2,"code":"706","name":"هزینه های استهلاک","parentId":2540,"accountType":0},
|
||||
{"id":2573,"level":3,"code":"70601","name":"هزینه استهلاک ساختمان","parentId":2572,"accountType":0},
|
||||
{"id":2574,"level":3,"code":"70602","name":"هزینه استهلاک وسائط نقلیه","parentId":2572,"accountType":0},
|
||||
{"id":2575,"level":3,"code":"70603","name":"هزینه استهلاک اثاثیه","parentId":2572,"accountType":0},
|
||||
{"id":2576,"level":2,"code":"707","name":"هزینه های بازاریابی و توزیع و فروش","parentId":2540,"accountType":0},
|
||||
{"id":2577,"level":3,"code":"70701","name":"هزینه آگهی و تبلیغات","parentId":2576,"accountType":0},
|
||||
{"id":2578,"level":3,"code":"70702","name":"هزینه بازاریابی و پورسانت","parentId":2576,"accountType":0},
|
||||
{"id":2579,"level":3,"code":"70703","name":"سایر هزینه های توزیع و فروش","parentId":2576,"accountType":0},
|
||||
{"id":2580,"level":2,"code":"708","name":"هزینه های غیرعملیاتی","parentId":2540,"accountType":0},
|
||||
{"id":2581,"level":3,"code":"709","name":"هزینه های بانکی","parentId":2580,"accountType":0},
|
||||
{"id":2582,"level":4,"code":"70901","name":"سود و کارمزد وامها","parentId":2581,"accountType":0},
|
||||
{"id":2583,"level":4,"code":"70902","name":"کارمزد خدمات بانکی","parentId":2581,"accountType":33},
|
||||
{"id":2584,"level":4,"code":"70903","name":"جرائم دیرکرد بانکی","parentId":2581,"accountType":0},
|
||||
{"id":2585,"level":3,"code":"70801","name":"هزینه تسعیر ارز","parentId":2580,"accountType":37},
|
||||
{"id":2586,"level":3,"code":"70802","name":"هزینه مطالبات سوخت شده","parentId":2580,"accountType":0},
|
||||
{"id":2587,"level":1,"code":"8","name":"سایر حساب ها","parentId":0,"accountType":0},
|
||||
{"id":2588,"level":2,"code":"801","name":"حساب های انتظامی","parentId":2587,"accountType":0},
|
||||
{"id":2589,"level":3,"code":"80101","name":"حساب های انتظامی","parentId":2588,"accountType":0},
|
||||
{"id":2590,"level":3,"code":"80102","name":"طرف حساب های انتظامی","parentId":2588,"accountType":0},
|
||||
{"id":2591,"level":2,"code":"802","name":"حساب های کنترلی","parentId":2587,"accountType":0},
|
||||
{"id":2592,"level":3,"code":"80201","name":"کنترل کسری و اضافه کالا","parentId":2591,"accountType":34},
|
||||
{"id":2593,"level":2,"code":"803","name":"حساب خلاصه سود و زیان","parentId":2587,"accountType":0},
|
||||
{"id":2594,"level":3,"code":"80301","name":"خلاصه سود و زیان","parentId":2593,"accountType":35},
|
||||
{"id":2595,"level":5,"code":"70503","name":"هزینه آب","parentId":2567,"accountType":0},
|
||||
{"id":2596,"level":5,"code":"70504","name":"هزینه برق","parentId":2567,"accountType":0},
|
||||
{"id":2597,"level":5,"code":"70505","name":"هزینه گاز","parentId":2567,"accountType":0},
|
||||
{"id":2598,"level":5,"code":"70506","name":"هزینه تلفن","parentId":2567,"accountType":0},
|
||||
{"id":2600,"level":4,"code":"20503","name":"وام از بانک ملت","parentId":2511,"accountType":0},
|
||||
{"id":2601,"level":4,"code":"10405","name":"سود تحقق نیافته فروش اقساطی","parentId":2463,"accountType":39},
|
||||
{"id":2602,"level":3,"code":"60205","name":"سود فروش اقساطی","parentId":2535,"accountType":38},
|
||||
{"id":2603,"level":4,"code":"70214","name":"حق تاهل","parentId":2542,"accountType":0},
|
||||
{"id":2604,"level":4,"code":"20504","name":"وام از بانک پارسیان","parentId":2511,"accountType":0},
|
||||
{"id":2605,"level":3,"code":"10105","name":"مساعده","parentId":2453,"accountType":0},
|
||||
{"id":2606,"level":3,"code":"60105","name":"تعمیرات لوازم آشپزخانه","parentId":2530,"accountType":0},
|
||||
{"id":2607,"level":4,"code":"10705","name":"کامپیوتر","parentId":2476,"accountType":0},
|
||||
{"id":2608,"level":3,"code":"60206","name":"درامد حاصل از فروش ضایعات","parentId":2535,"accountType":0},
|
||||
{"id":2609,"level":3,"code":"60207","name":"سود فروش دارایی","parentId":2535,"accountType":0},
|
||||
{"id":2610,"level":3,"code":"70803","name":"زیان فروش دارایی","parentId":2580,"accountType":0},
|
||||
{"id":2611,"level":3,"code":"10106","name":"موجودی کالای در جریان ساخت","parentId":2453,"accountType":41},
|
||||
{"id":2612,"level":3,"code":"20102","name":"سربار تولید پرداختنی","parentId":2491,"accountType":43},
|
||||
]
|
||||
|
||||
# نقشه id خارجی به id داخلی
|
||||
ext_to_internal: dict[int, int] = {}
|
||||
|
||||
# کوئریها
|
||||
select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1")
|
||||
insert_q = sa.text(
|
||||
"""
|
||||
INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at)
|
||||
VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW())
|
||||
"""
|
||||
)
|
||||
update_q = sa.text(
|
||||
"""
|
||||
UPDATE accounts
|
||||
SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
|
||||
for item in accounts:
|
||||
parent_internal = None
|
||||
if item.get("parentId") and item["parentId"] in ext_to_internal:
|
||||
parent_internal = ext_to_internal[item["parentId"]]
|
||||
|
||||
# وجودی؟
|
||||
res = conn.execute(select_existing, {"code": item["code"]})
|
||||
row = res.fetchone()
|
||||
if row is None:
|
||||
result = conn.execute(
|
||||
insert_q,
|
||||
{
|
||||
"name": item["name"],
|
||||
"account_type": str(item.get("accountType", 0)),
|
||||
"code": item["code"],
|
||||
"parent_id": parent_internal,
|
||||
},
|
||||
)
|
||||
new_id = result.lastrowid if hasattr(result, "lastrowid") else None
|
||||
if new_id is None:
|
||||
# fallback: انتخاب بر اساس code
|
||||
res2 = conn.execute(select_existing, {"code": item["code"]})
|
||||
row2 = res2.fetchone()
|
||||
if row2:
|
||||
new_id = row2[0]
|
||||
else:
|
||||
pass
|
||||
if new_id is not None:
|
||||
ext_to_internal[item["id"]] = int(new_id)
|
||||
else:
|
||||
acc_id = int(row[0])
|
||||
conn.execute(
|
||||
update_q,
|
||||
{
|
||||
"id": acc_id,
|
||||
"name": item["name"],
|
||||
"account_type": str(item.get("accountType", 0)),
|
||||
"parent_id": parent_internal,
|
||||
},
|
||||
)
|
||||
ext_to_internal[item["id"]] = acc_id
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# حذف بر اساس کدها (فقط حسابهای عمومی یعنی business_id IS NULL)
|
||||
codes = [
|
||||
"1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102"
|
||||
]
|
||||
delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
|
||||
for code in codes:
|
||||
conn.execute(delete_q, {"code": code})
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000020_add_share_count_and_shareholder_type'
|
||||
down_revision = '20250927_000019_seed_accounts_chart'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
b = op.get_bind()
|
||||
inspector = inspect(b)
|
||||
cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
|
||||
with op.batch_alter_table('persons') as batch_op:
|
||||
if 'share_count' not in cols:
|
||||
batch_op.add_column(sa.Column('share_count', sa.Integer(), nullable=True))
|
||||
|
||||
# افزودن مقدار جدید به ENUM ستون person_type (برای MySQL)
|
||||
# مقادیر فارسی مطابق Enum مدل: 'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده'
|
||||
# مقدار جدید: 'سهامدار'
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE persons
|
||||
MODIFY COLUMN person_type
|
||||
ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('persons') as batch_op:
|
||||
batch_op.drop_column('share_count')
|
||||
|
||||
# بازگردانی ENUM بدون مقدار سهامدار
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE persons
|
||||
MODIFY COLUMN person_type
|
||||
ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده') NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000021_update_person_type_enum_to_persian'
|
||||
down_revision = 'd3e84892c1c2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) Allow both English and Persian, plus new 'سهامدار'
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE persons
|
||||
MODIFY COLUMN person_type
|
||||
ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER',
|
||||
'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# 2) Migrate existing data from English to Persian
|
||||
op.execute("UPDATE persons SET person_type = 'مشتری' WHERE person_type = 'CUSTOMER'")
|
||||
op.execute("UPDATE persons SET person_type = 'بازاریاب' WHERE person_type = 'MARKETER'")
|
||||
op.execute("UPDATE persons SET person_type = 'کارمند' WHERE person_type = 'EMPLOYEE'")
|
||||
op.execute("UPDATE persons SET person_type = 'تامینکننده' WHERE person_type = 'SUPPLIER'")
|
||||
op.execute("UPDATE persons SET person_type = 'همکار' WHERE person_type = 'PARTNER'")
|
||||
op.execute("UPDATE persons SET person_type = 'فروشنده' WHERE person_type = 'SELLER'")
|
||||
|
||||
# 3) Restrict enum to Persian only (including 'سهامدار')
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE persons
|
||||
MODIFY COLUMN person_type
|
||||
ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert to English-only (without shareholder)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE persons
|
||||
MODIFY COLUMN person_type
|
||||
ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER') NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Convert data back from Persian to English
|
||||
reverse_mapping = {
|
||||
'مشتری': 'CUSTOMER',
|
||||
'بازاریاب': 'MARKETER',
|
||||
'کارمند': 'EMPLOYEE',
|
||||
'تامینکننده': 'SUPPLIER',
|
||||
'همکار': 'PARTNER',
|
||||
'فروشنده': 'SELLER',
|
||||
}
|
||||
for fa, en in reverse_mapping.items():
|
||||
op.execute(text("UPDATE persons SET person_type = :en WHERE person_type = :fa"), {"fa": fa, "en": en})
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000022_add_person_commission_fields'
|
||||
down_revision = '20250927_000021_update_person_type_enum_to_persian'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
|
||||
with op.batch_alter_table('persons') as batch_op:
|
||||
if 'commission_sale_percent' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_sale_percent', sa.Numeric(5, 2), nullable=True))
|
||||
if 'commission_sales_return_percent' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_sales_return_percent', sa.Numeric(5, 2), nullable=True))
|
||||
if 'commission_sales_amount' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_sales_amount', sa.Numeric(12, 2), nullable=True))
|
||||
if 'commission_sales_return_amount' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_sales_return_amount', sa.Numeric(12, 2), nullable=True))
|
||||
if 'commission_exclude_discounts' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_exclude_discounts', sa.Boolean(), server_default=sa.text('0'), nullable=False))
|
||||
if 'commission_exclude_additions_deductions' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_exclude_additions_deductions', sa.Boolean(), server_default=sa.text('0'), nullable=False))
|
||||
if 'commission_post_in_invoice_document' not in cols:
|
||||
batch_op.add_column(sa.Column('commission_post_in_invoice_document', sa.Boolean(), server_default=sa.text('0'), nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('persons') as batch_op:
|
||||
batch_op.drop_column('commission_post_in_invoice_document')
|
||||
batch_op.drop_column('commission_exclude_additions_deductions')
|
||||
batch_op.drop_column('commission_exclude_discounts')
|
||||
batch_op.drop_column('commission_sales_return_amount')
|
||||
batch_op.drop_column('commission_sales_amount')
|
||||
batch_op.drop_column('commission_sales_return_percent')
|
||||
batch_op.drop_column('commission_sale_percent')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"""sync person_type enum values_callable to persian
|
||||
|
||||
Revision ID: d3e84892c1c2
|
||||
Revises: 20250927_000020_add_share_count_and_shareholder_type
|
||||
Create Date: 2025-09-27 19:18:06.253391
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd3e84892c1c2'
|
||||
down_revision = '20250927_000020_add_share_count_and_shareholder_type'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('storage_configs',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('storage_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('config_data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('file_storage',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('original_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('stored_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=500), nullable=False),
|
||||
sa.Column('file_size', sa.Integer(), nullable=False),
|
||||
sa.Column('mime_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('storage_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('storage_config_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('uploaded_by', sa.Integer(), nullable=False),
|
||||
sa.Column('module_context', sa.String(length=50), nullable=False),
|
||||
sa.Column('context_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('checksum', sa.String(length=64), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_temporary', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('verification_token', sa.String(length=100), nullable=True),
|
||||
sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('file_verifications',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('file_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('module_name', sa.String(length=50), nullable=False),
|
||||
sa.Column('verification_token', sa.String(length=100), nullable=False),
|
||||
sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('verified_by', sa.Integer(), nullable=True),
|
||||
sa.Column('verification_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ),
|
||||
sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years')
|
||||
op.alter_column('person_bank_accounts', 'person_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='شناسه شخص',
|
||||
existing_nullable=False)
|
||||
op.alter_column('persons', 'business_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='شناسه کسب و کار',
|
||||
existing_nullable=False)
|
||||
op.alter_column('persons', 'code',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='کد یکتا در هر کسب و کار',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'person_types',
|
||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||
comment='لیست انواع شخص به صورت JSON',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'share_count',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='تعداد سهام (فقط برای سهامدار)',
|
||||
existing_nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('persons', 'share_count',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='تعداد سهام (فقط برای سهامدار)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'person_types',
|
||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||
comment=None,
|
||||
existing_comment='لیست انواع شخص به صورت JSON',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'code',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='کد یکتا در هر کسب و کار',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'business_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='شناسه کسب و کار',
|
||||
existing_nullable=False)
|
||||
op.alter_column('person_bank_accounts', 'person_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='شناسه شخص',
|
||||
existing_nullable=False)
|
||||
op.create_index(op.f('ix_fiscal_years_title'), 'fiscal_years', ['title'], unique=False)
|
||||
op.drop_table('file_verifications')
|
||||
op.drop_table('file_storage')
|
||||
op.drop_table('storage_configs')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""merge multiple heads
|
||||
|
||||
Revision ID: f876bfa36805
|
||||
Revises: 20250117_000009, 20250120_000002, 20250927_000017_add_account_id_to_document_lines
|
||||
Create Date: 2025-09-27 12:29:57.080003
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f876bfa36805'
|
||||
down_revision = ('20250117_000009', '20250120_000002', '20250927_000017_add_account_id_to_document_lines')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
|
|
@ -71,7 +71,7 @@ class ApiClient {
|
|||
if (calendarType != null && calendarType.isNotEmpty) {
|
||||
options.headers['X-Calendar-Type'] = calendarType;
|
||||
}
|
||||
// Inject X-Business-ID header when path targets a specific business
|
||||
// Inject X-Business-ID header when request targets a specific business
|
||||
try {
|
||||
final uri = options.uri;
|
||||
final path = uri.path;
|
||||
|
|
@ -80,7 +80,8 @@ class ApiClient {
|
|||
int? resolvedBusinessId = currentBusinessId;
|
||||
// Fallback: detect business_id from URL like /api/v1/business/{id}/...
|
||||
if (resolvedBusinessId == null) {
|
||||
final match = RegExp(r"/api/v1/business/(\d+)/").firstMatch(path);
|
||||
// Match any occurrence of /business/{id} in the path
|
||||
final match = RegExp(r"/business/(\d+)(/|$)").firstMatch(path);
|
||||
if (match != null) {
|
||||
final idStr = match.group(1);
|
||||
if (idStr != null) {
|
||||
|
|
@ -88,6 +89,14 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fallback: query parameter business_id or businessId
|
||||
if (resolvedBusinessId == null && uri.queryParameters.isNotEmpty) {
|
||||
final qp = uri.queryParameters;
|
||||
final idStr = qp['business_id'] ?? qp['businessId'];
|
||||
if (idStr != null && idStr.isNotEmpty) {
|
||||
resolvedBusinessId = int.tryParse(idStr);
|
||||
}
|
||||
}
|
||||
if (resolvedBusinessId != null) {
|
||||
options.headers['X-Business-ID'] = resolvedBusinessId.toString();
|
||||
}
|
||||
|
|
@ -121,6 +130,7 @@ class ApiClient {
|
|||
}
|
||||
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
|
||||
path = _resolveApiPath(path);
|
||||
final requestOptions = options ?? Options();
|
||||
if (responseType != null) {
|
||||
requestOptions.responseType = responseType;
|
||||
|
|
@ -129,6 +139,7 @@ class ApiClient {
|
|||
}
|
||||
|
||||
Future<Response<T>> post<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
|
||||
path = _resolveApiPath(path);
|
||||
final requestOptions = options ?? Options();
|
||||
if (responseType != null) {
|
||||
requestOptions.responseType = responseType;
|
||||
|
|
@ -137,14 +148,17 @@ class ApiClient {
|
|||
}
|
||||
|
||||
Future<Response<T>> put<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
|
||||
path = _resolveApiPath(path);
|
||||
return _dio.put<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<Response<T>> patch<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
|
||||
path = _resolveApiPath(path);
|
||||
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<Response<T>> delete<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
|
||||
path = _resolveApiPath(path);
|
||||
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
|
|
@ -165,4 +179,19 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Utilities
|
||||
String _resolveApiPath(String path) {
|
||||
// Absolute URL → leave as is
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// Ensure leading slash
|
||||
final p = path.startsWith('/') ? path : '/$path';
|
||||
// If already versioned, keep
|
||||
if (p.startsWith('/api/')) {
|
||||
return p;
|
||||
}
|
||||
// Auto-prefix with api version
|
||||
return '/api/v1$p'.replaceAll(RegExp(r'//+'), '/');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'pages/admin/email_settings_page.dart';
|
|||
import 'pages/business/business_shell.dart';
|
||||
import 'pages/business/dashboard/business_dashboard_page.dart';
|
||||
import 'pages/business/users_permissions_page.dart';
|
||||
import 'pages/business/accounts_page.dart';
|
||||
import 'pages/business/settings_page.dart';
|
||||
import 'pages/business/persons_page.dart';
|
||||
import 'pages/error_404_page.dart';
|
||||
|
|
@ -324,6 +325,19 @@ class _MyAppState extends State<MyApp> {
|
|||
// برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند)
|
||||
// این مهم است: اگر کاربر در صفحات profile یا business است، بماند
|
||||
print('🔍 REDIRECT DEBUG: On other page ($currentPath), staying on current path');
|
||||
// ذخیره مسیر فعلی به عنوان آخرین URL معتبر
|
||||
if (currentPath.isNotEmpty &&
|
||||
currentPath != '/' &&
|
||||
currentPath != '/login' &&
|
||||
(currentPath.startsWith('/user/profile/') || currentPath.startsWith('/business/'))) {
|
||||
try {
|
||||
await _authStore!.saveLastUrl(currentPath);
|
||||
print('🔍 REDIRECT DEBUG: Saved last URL: $currentPath');
|
||||
} catch (e) {
|
||||
// صرفاً لاگ برای خطای غیر بحرانی ذخیره آدرس
|
||||
print('🔍 REDIRECT DEBUG: Error saving last URL: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
|
|
@ -354,7 +368,7 @@ class _MyAppState extends State<MyApp> {
|
|||
GoRoute(
|
||||
path: '/user/profile/new-business',
|
||||
name: 'profile_new_business',
|
||||
builder: (context, state) => const NewBusinessPage(),
|
||||
builder: (context, state) => NewBusinessPage(calendarController: _calendarController!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/businesses',
|
||||
|
|
@ -509,6 +523,36 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'chart-of-accounts',
|
||||
name: 'business_chart_of_accounts',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: AccountsPage(businessId: businessId),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'accounts',
|
||||
name: 'business_accounts',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: AccountsPage(businessId: businessId),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
name: 'business_settings',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
enum BusinessType {
|
||||
company('شرکت'),
|
||||
shop('مغازه'),
|
||||
|
|
@ -43,6 +44,9 @@ class BusinessData {
|
|||
String? province;
|
||||
String? city;
|
||||
|
||||
// مرحله 5: سال(های) مالی
|
||||
List<FiscalYearData> fiscalYears;
|
||||
|
||||
BusinessData({
|
||||
this.name = '',
|
||||
this.businessType,
|
||||
|
|
@ -57,14 +61,16 @@ class BusinessData {
|
|||
this.country,
|
||||
this.province,
|
||||
this.city,
|
||||
});
|
||||
List<FiscalYearData>? fiscalYears,
|
||||
}) : fiscalYears = fiscalYears ?? <FiscalYearData>[];
|
||||
|
||||
// تبدیل به Map برای ارسال به API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'business_type': businessType?.name,
|
||||
'business_field': businessField?.name,
|
||||
// بکاند انتظار مقادیر فارسی enum را دارد
|
||||
'business_type': businessType?.displayName,
|
||||
'business_field': businessField?.displayName,
|
||||
'address': address,
|
||||
'phone': phone,
|
||||
'mobile': mobile,
|
||||
|
|
@ -75,6 +81,7 @@ class BusinessData {
|
|||
'country': country,
|
||||
'province': province,
|
||||
'city': city,
|
||||
'fiscal_years': fiscalYears.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +100,7 @@ class BusinessData {
|
|||
String? country,
|
||||
String? province,
|
||||
String? city,
|
||||
List<FiscalYearData>? fiscalYears,
|
||||
}) {
|
||||
return BusinessData(
|
||||
name: name ?? this.name,
|
||||
|
|
@ -108,6 +116,7 @@ class BusinessData {
|
|||
country: country ?? this.country,
|
||||
province: province ?? this.province,
|
||||
city: city ?? this.city,
|
||||
fiscalYears: fiscalYears ?? this.fiscalYears,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -147,14 +156,23 @@ class BusinessData {
|
|||
return true;
|
||||
}
|
||||
|
||||
// بررسی اعتبار مرحله 4 (اختیاری)
|
||||
// بررسی اعتبار مرحله 4 (اطلاعات جغرافیایی - اختیاری)
|
||||
bool isStep4Valid() {
|
||||
return true; // همه فیلدها اختیاری هستند
|
||||
return true;
|
||||
}
|
||||
|
||||
// بررسی اعتبار مرحله 5 (سال مالی - اجباری)
|
||||
bool isFiscalStepValid() {
|
||||
if (fiscalYears.isEmpty) return false;
|
||||
final fy = fiscalYears.first;
|
||||
if (fy.title.trim().isEmpty || fy.startDate == null || fy.endDate == null) return false;
|
||||
if (fy.startDate!.isAfter(fy.endDate!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// بررسی اعتبار کل فرم
|
||||
bool isFormValid() {
|
||||
return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid();
|
||||
return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid() && isFiscalStepValid();
|
||||
}
|
||||
|
||||
// اعتبارسنجی شماره موبایل ایرانی
|
||||
|
|
@ -251,6 +269,29 @@ class BusinessData {
|
|||
}
|
||||
}
|
||||
|
||||
class FiscalYearData {
|
||||
String title;
|
||||
DateTime? startDate;
|
||||
DateTime? endDate;
|
||||
bool isLast;
|
||||
|
||||
FiscalYearData({
|
||||
this.title = '',
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.isLast = true,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'start_date': startDate?.toIso8601String().split('T').first,
|
||||
'end_date': endDate?.toIso8601String().split('T').first,
|
||||
'is_last': isLast,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BusinessResponse {
|
||||
final int id;
|
||||
final String name;
|
||||
|
|
@ -307,8 +348,54 @@ class BusinessResponse {
|
|||
province: json['province'],
|
||||
city: json['city'],
|
||||
postalCode: json['postal_code'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
createdAt: _parseDateTime(json['created_at'] ?? json['created_at_raw']),
|
||||
updatedAt: _parseDateTime(json['updated_at'] ?? json['updated_at_raw']),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _parseDateTime(dynamic value) {
|
||||
if (value == null) return DateTime.now();
|
||||
if (value is DateTime) return value;
|
||||
if (value is int) {
|
||||
// epoch ms
|
||||
return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
}
|
||||
if (value is String) {
|
||||
// Jalali format: YYYY/MM/DD [HH:MM:SS]
|
||||
if (value.contains('/') && !value.contains('-')) {
|
||||
try {
|
||||
final parts = value.split(' ');
|
||||
final dateParts = parts[0].split('/');
|
||||
if (dateParts.length == 3) {
|
||||
final year = int.parse(dateParts[0]);
|
||||
final month = int.parse(dateParts[1]);
|
||||
final day = int.parse(dateParts[2]);
|
||||
int hour = 0, minute = 0, second = 0;
|
||||
if (parts.length > 1) {
|
||||
final timeParts = parts[1].split(':');
|
||||
if (timeParts.length >= 2) {
|
||||
hour = int.parse(timeParts[0]);
|
||||
minute = int.parse(timeParts[1]);
|
||||
if (timeParts.length >= 3) {
|
||||
second = int.parse(timeParts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
final j = Jalali(year, month, day);
|
||||
final dt = j.toDateTime();
|
||||
return DateTime(dt.year, dt.month, dt.day, hour, minute, second);
|
||||
}
|
||||
} catch (_) {
|
||||
// fallthrough
|
||||
}
|
||||
}
|
||||
// ISO or other parseable formats
|
||||
try {
|
||||
return DateTime.parse(value);
|
||||
} catch (_) {
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class BusinessUser {
|
|||
try {
|
||||
// Parse Jalali date format: YYYY/MM/DD HH:MM:SS
|
||||
final parts = dateValue.split(' ');
|
||||
if (parts.length >= 1) {
|
||||
if (parts.isNotEmpty) {
|
||||
final dateParts = parts[0].split('/');
|
||||
if (dateParts.length == 3) {
|
||||
final year = int.parse(dateParts[0]);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ enum PersonType {
|
|||
employee('کارمند', 'Employee'),
|
||||
supplier('تامینکننده', 'Supplier'),
|
||||
partner('همکار', 'Partner'),
|
||||
seller('فروشنده', 'Seller');
|
||||
seller('فروشنده', 'Seller'),
|
||||
shareholder('سهامدار', 'Shareholder');
|
||||
|
||||
const PersonType(this.persianName, this.englishName);
|
||||
final String persianName;
|
||||
|
|
@ -122,6 +123,15 @@ class Person {
|
|||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<PersonBankAccount> bankAccounts;
|
||||
final int? shareCount;
|
||||
// پورسانت
|
||||
final double? commissionSalePercent;
|
||||
final double? commissionSalesReturnPercent;
|
||||
final double? commissionSalesAmount;
|
||||
final double? commissionSalesReturnAmount;
|
||||
final bool commissionExcludeDiscounts;
|
||||
final bool commissionExcludeAdditionsDeductions;
|
||||
final bool commissionPostInInvoiceDocument;
|
||||
|
||||
Person({
|
||||
this.id,
|
||||
|
|
@ -151,6 +161,14 @@ class Person {
|
|||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.bankAccounts = const [],
|
||||
this.shareCount,
|
||||
this.commissionSalePercent,
|
||||
this.commissionSalesReturnPercent,
|
||||
this.commissionSalesAmount,
|
||||
this.commissionSalesReturnAmount,
|
||||
this.commissionExcludeDiscounts = false,
|
||||
this.commissionExcludeAdditionsDeductions = false,
|
||||
this.commissionPostInInvoiceDocument = false,
|
||||
});
|
||||
|
||||
factory Person.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -191,6 +209,14 @@ class Person {
|
|||
bankAccounts: (json['bank_accounts'] as List<dynamic>?)
|
||||
?.map((ba) => PersonBankAccount.fromJson(ba))
|
||||
.toList() ?? [],
|
||||
shareCount: json['share_count'],
|
||||
commissionSalePercent: (json['commission_sale_percent'] as num?)?.toDouble(),
|
||||
commissionSalesReturnPercent: (json['commission_sales_return_percent'] as num?)?.toDouble(),
|
||||
commissionSalesAmount: (json['commission_sales_amount'] as num?)?.toDouble(),
|
||||
commissionSalesReturnAmount: (json['commission_sales_return_amount'] as num?)?.toDouble(),
|
||||
commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false,
|
||||
commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false,
|
||||
commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +249,14 @@ class Person {
|
|||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(),
|
||||
'share_count': shareCount,
|
||||
'commission_sale_percent': commissionSalePercent,
|
||||
'commission_sales_return_percent': commissionSalesReturnPercent,
|
||||
'commission_sales_amount': commissionSalesAmount,
|
||||
'commission_sales_return_amount': commissionSalesReturnAmount,
|
||||
'commission_exclude_discounts': commissionExcludeDiscounts,
|
||||
'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions,
|
||||
'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -320,6 +354,14 @@ class PersonCreateRequest {
|
|||
final String? email;
|
||||
final String? website;
|
||||
final List<PersonBankAccount> bankAccounts;
|
||||
final int? shareCount;
|
||||
final double? commissionSalePercent;
|
||||
final double? commissionSalesReturnPercent;
|
||||
final double? commissionSalesAmount;
|
||||
final double? commissionSalesReturnAmount;
|
||||
final bool? commissionExcludeDiscounts;
|
||||
final bool? commissionExcludeAdditionsDeductions;
|
||||
final bool? commissionPostInInvoiceDocument;
|
||||
|
||||
PersonCreateRequest({
|
||||
required this.aliasName,
|
||||
|
|
@ -343,6 +385,14 @@ class PersonCreateRequest {
|
|||
this.email,
|
||||
this.website,
|
||||
this.bankAccounts = const [],
|
||||
this.shareCount,
|
||||
this.commissionSalePercent,
|
||||
this.commissionSalesReturnPercent,
|
||||
this.commissionSalesAmount,
|
||||
this.commissionSalesReturnAmount,
|
||||
this.commissionExcludeDiscounts,
|
||||
this.commissionExcludeAdditionsDeductions,
|
||||
this.commissionPostInInvoiceDocument,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -377,6 +427,14 @@ class PersonCreateRequest {
|
|||
'sheba_number': ba.shebaNumber,
|
||||
})
|
||||
.toList(),
|
||||
if (shareCount != null) 'share_count': shareCount,
|
||||
if (commissionSalePercent != null) 'commission_sale_percent': commissionSalePercent,
|
||||
if (commissionSalesReturnPercent != null) 'commission_sales_return_percent': commissionSalesReturnPercent,
|
||||
if (commissionSalesAmount != null) 'commission_sales_amount': commissionSalesAmount,
|
||||
if (commissionSalesReturnAmount != null) 'commission_sales_return_amount': commissionSalesReturnAmount,
|
||||
if (commissionExcludeDiscounts != null) 'commission_exclude_discounts': commissionExcludeDiscounts,
|
||||
if (commissionExcludeAdditionsDeductions != null) 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions,
|
||||
if (commissionPostInInvoiceDocument != null) 'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -404,6 +462,14 @@ class PersonUpdateRequest {
|
|||
final String? email;
|
||||
final String? website;
|
||||
final bool? isActive;
|
||||
final int? shareCount;
|
||||
final double? commissionSalePercent;
|
||||
final double? commissionSalesReturnPercent;
|
||||
final double? commissionSalesAmount;
|
||||
final double? commissionSalesReturnAmount;
|
||||
final bool? commissionExcludeDiscounts;
|
||||
final bool? commissionExcludeAdditionsDeductions;
|
||||
final bool? commissionPostInInvoiceDocument;
|
||||
|
||||
PersonUpdateRequest({
|
||||
this.code,
|
||||
|
|
@ -428,6 +494,14 @@ class PersonUpdateRequest {
|
|||
this.email,
|
||||
this.website,
|
||||
this.isActive,
|
||||
this.shareCount,
|
||||
this.commissionSalePercent,
|
||||
this.commissionSalesReturnPercent,
|
||||
this.commissionSalesAmount,
|
||||
this.commissionSalesReturnAmount,
|
||||
this.commissionExcludeDiscounts,
|
||||
this.commissionExcludeAdditionsDeductions,
|
||||
this.commissionPostInInvoiceDocument,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -455,6 +529,14 @@ class PersonUpdateRequest {
|
|||
if (email != null) json['email'] = email;
|
||||
if (website != null) json['website'] = website;
|
||||
if (isActive != null) json['is_active'] = isActive;
|
||||
if (shareCount != null) json['share_count'] = shareCount;
|
||||
if (commissionSalePercent != null) json['commission_sale_percent'] = commissionSalePercent;
|
||||
if (commissionSalesReturnPercent != null) json['commission_sales_return_percent'] = commissionSalesReturnPercent;
|
||||
if (commissionSalesAmount != null) json['commission_sales_amount'] = commissionSalesAmount;
|
||||
if (commissionSalesReturnAmount != null) json['commission_sales_return_amount'] = commissionSalesReturnAmount;
|
||||
if (commissionExcludeDiscounts != null) json['commission_exclude_discounts'] = commissionExcludeDiscounts;
|
||||
if (commissionExcludeAdditionsDeductions != null) json['commission_exclude_additions_deductions'] = commissionExcludeAdditionsDeductions;
|
||||
if (commissionPostInInvoiceDocument != null) json['commission_post_in_invoice_document'] = commissionPostInInvoiceDocument;
|
||||
|
||||
return json;
|
||||
}
|
||||
|
|
|
|||
67
hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
Normal file
67
hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
|
||||
class AccountsPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
const AccountsPage({super.key, required this.businessId});
|
||||
|
||||
@override
|
||||
State<AccountsPage> createState() => _AccountsPageState();
|
||||
}
|
||||
|
||||
class _AccountsPageState extends State<AccountsPage> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<dynamic> _tree = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetch();
|
||||
}
|
||||
|
||||
Future<void> _fetch() async {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final api = ApiClient();
|
||||
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
|
||||
setState(() { _tree = res.data['data']['items'] ?? []; });
|
||||
} catch (e) {
|
||||
setState(() { _error = e.toString(); });
|
||||
} finally {
|
||||
setState(() { _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNode(Map<String, dynamic> node) {
|
||||
final children = (node['children'] as List?) ?? const [];
|
||||
if (children.isEmpty) {
|
||||
return ListTile(
|
||||
title: Text('${node['code']} - ${node['name']}'),
|
||||
);
|
||||
}
|
||||
return ExpansionTile(
|
||||
title: Text('${node['code']} - ${node['name']}'),
|
||||
children: children.map<Widget>((c) => _buildNode(Map<String, dynamic>.from(c))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) return Center(child: Text(_error!));
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _fetch,
|
||||
child: ListView(
|
||||
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -460,7 +460,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
context.go('/login');
|
||||
}
|
||||
|
||||
Future<void> _showAddPersonDialog() async {
|
||||
Future<void> showAddPersonDialog() async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => PersonFormDialog(
|
||||
|
|
@ -647,7 +647,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
// Navigate to add new item
|
||||
if (child.label == t.personsList) {
|
||||
// Navigate to add person
|
||||
_showAddPersonDialog();
|
||||
showAddPersonDialog();
|
||||
} else if (child.label == t.products) {
|
||||
// Navigate to add product
|
||||
} else if (child.label == t.priceLists) {
|
||||
|
|
@ -802,7 +802,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (item.label == t.people) {
|
||||
_showAddPersonDialog();
|
||||
showAddPersonDialog();
|
||||
}
|
||||
// سایر مسیرهای افزودن در آینده متصل میشوند
|
||||
},
|
||||
|
|
@ -898,7 +898,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
context.pop();
|
||||
// در حال حاضر فقط اشخاص پشتیبانی میشود
|
||||
if (item.label == t.people) {
|
||||
_showAddPersonDialog();
|
||||
showAddPersonDialog();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
|
|
|
|||
|
|
@ -38,22 +38,6 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.personsList),
|
||||
actions: [
|
||||
// دکمه اضافه کردن فقط در صورت داشتن دسترسی
|
||||
PermissionButton(
|
||||
section: 'people',
|
||||
action: 'add',
|
||||
authStore: widget.authStore,
|
||||
child: IconButton(
|
||||
onPressed: _addPerson,
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: t.addPerson,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: DataTableWidget<Person>(
|
||||
key: _personsTableKey,
|
||||
config: _buildDataTableConfig(t),
|
||||
|
|
@ -66,6 +50,14 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
return DataTableConfig<Person>(
|
||||
endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons',
|
||||
title: t.personsList,
|
||||
excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
|
||||
pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
|
||||
getExportParams: () => {
|
||||
'business_id': widget.businessId,
|
||||
},
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
showTableIcon: false,
|
||||
showRowNumbers: true,
|
||||
enableRowSelection: true,
|
||||
columns: [
|
||||
|
|
@ -131,6 +123,105 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
'تاریخ ایجاد',
|
||||
width: ColumnWidth.medium,
|
||||
),
|
||||
NumberColumn(
|
||||
'share_count',
|
||||
'تعداد سهام',
|
||||
width: ColumnWidth.small,
|
||||
textAlign: TextAlign.center,
|
||||
decimalPlaces: 0,
|
||||
),
|
||||
NumberColumn(
|
||||
'commission_sale_percent',
|
||||
'درصد پورسانت فروش',
|
||||
width: ColumnWidth.medium,
|
||||
decimalPlaces: 2,
|
||||
suffix: '٪',
|
||||
),
|
||||
NumberColumn(
|
||||
'commission_sales_return_percent',
|
||||
'درصد پورسانت برگشت از فروش',
|
||||
width: ColumnWidth.medium,
|
||||
decimalPlaces: 2,
|
||||
suffix: '٪',
|
||||
),
|
||||
NumberColumn(
|
||||
'commission_sales_amount',
|
||||
'مبلغ پورسانت فروش',
|
||||
width: ColumnWidth.large,
|
||||
decimalPlaces: 0,
|
||||
),
|
||||
NumberColumn(
|
||||
'commission_sales_return_amount',
|
||||
'مبلغ پورسانت برگشت از فروش',
|
||||
width: ColumnWidth.large,
|
||||
decimalPlaces: 0,
|
||||
),
|
||||
TextColumn(
|
||||
'payment_id',
|
||||
t.personPaymentId,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.paymentId ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'registration_number',
|
||||
t.personRegistrationNumber,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.registrationNumber ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'economic_id',
|
||||
t.personEconomicId,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.economicId ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'country',
|
||||
t.personCountry,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.country ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'province',
|
||||
t.personProvince,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.province ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'city',
|
||||
t.personCity,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.city ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'address',
|
||||
t.personAddress,
|
||||
width: ColumnWidth.extraLarge,
|
||||
formatter: (person) => person.address ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'postal_code',
|
||||
t.personPostalCode,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.postalCode ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'phone',
|
||||
t.personPhone,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.phone ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'fax',
|
||||
t.personFax,
|
||||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.fax ?? '-',
|
||||
),
|
||||
TextColumn(
|
||||
'website',
|
||||
t.personWebsite,
|
||||
width: ColumnWidth.large,
|
||||
formatter: (person) => person.website ?? '-',
|
||||
),
|
||||
ActionColumn(
|
||||
'actions',
|
||||
'عملیات',
|
||||
|
|
@ -167,6 +258,21 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
'province',
|
||||
],
|
||||
defaultPageSize: 20,
|
||||
// انتقال دکمه افزودن به اکشنهای هدر جدول با کنترل دسترسی
|
||||
customHeaderActions: [
|
||||
PermissionButton(
|
||||
section: 'people',
|
||||
action: 'add',
|
||||
authStore: widget.authStore,
|
||||
child: Tooltip(
|
||||
message: t.addPerson,
|
||||
child: IconButton(
|
||||
onPressed: _addPerson,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
|
|||
|
||||
Widget _buildUsersList(AppLocalizations t, ThemeData theme, ColorScheme colorScheme) {
|
||||
if (_loading) {
|
||||
return Container(
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: Center(
|
||||
child: Column(
|
||||
|
|
@ -439,7 +439,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
|
|||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Container(
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: Center(
|
||||
child: Column(
|
||||
|
|
@ -475,7 +475,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
|
|||
}
|
||||
|
||||
if (_filteredUsers.isEmpty) {
|
||||
return Container(
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: Center(
|
||||
child: Column(
|
||||
|
|
@ -881,23 +881,23 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
|
|||
'draft': '${t.draft} ${t.warehouseTransfers}',
|
||||
},
|
||||
'settings': {
|
||||
'business': '${t.businessSettings}',
|
||||
'print': '${t.printSettings}',
|
||||
'history': '${t.eventHistory}',
|
||||
'users': '${t.usersAndPermissions}',
|
||||
'business': t.businessSettings,
|
||||
'print': t.printSettings,
|
||||
'history': t.eventHistory,
|
||||
'users': t.usersAndPermissions,
|
||||
},
|
||||
'storage': {
|
||||
'view': '${t.view} ${t.storageSpace}',
|
||||
'delete': '${t.delete} ${t.deleteFiles}',
|
||||
},
|
||||
'sms': {
|
||||
'history': '${t.viewSmsHistory}',
|
||||
'templates': '${t.manageSmsTemplates}',
|
||||
'history': t.viewSmsHistory,
|
||||
'templates': t.manageSmsTemplates,
|
||||
},
|
||||
'marketplace': {
|
||||
'view': '${t.viewMarketplace}',
|
||||
'buy': '${t.buyPlugins}',
|
||||
'invoices': '${t.viewInvoices}',
|
||||
'view': t.viewMarketplace,
|
||||
'buy': t.buyPlugins,
|
||||
'invoices': t.viewInvoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1235,36 +1235,36 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
|
|||
String _inferCurrentSectionKey(String title, String description) {
|
||||
// جستجو بر اساس کلمات کلیدی ساده
|
||||
final pairs = <String, List<String>>{
|
||||
'people': ['${AppLocalizations.of(context).people}'],
|
||||
'people': [(AppLocalizations.of(context).people)],
|
||||
'people_transactions': [
|
||||
'${AppLocalizations.of(context).receiptsAndPayments}',
|
||||
'${AppLocalizations.of(context).receipts}',
|
||||
'${AppLocalizations.of(context).payments}',
|
||||
(AppLocalizations.of(context).receiptsAndPayments),
|
||||
(AppLocalizations.of(context).receipts),
|
||||
(AppLocalizations.of(context).payments),
|
||||
],
|
||||
'products': ['${AppLocalizations.of(context).products}'],
|
||||
'price_lists': ['${AppLocalizations.of(context).priceLists}'],
|
||||
'categories': ['${AppLocalizations.of(context).categories}'],
|
||||
'product_attributes': ['${AppLocalizations.of(context).productAttributes}'],
|
||||
'bank_accounts': ['${AppLocalizations.of(context).bankAccounts}'],
|
||||
'cash': ['${AppLocalizations.of(context).cash}'],
|
||||
'petty_cash': ['${AppLocalizations.of(context).pettyCash}'],
|
||||
'checks': ['${AppLocalizations.of(context).checks}'],
|
||||
'wallet': ['${AppLocalizations.of(context).wallet}'],
|
||||
'transfers': ['${AppLocalizations.of(context).transfers}'],
|
||||
'invoices': ['${AppLocalizations.of(context).invoices}'],
|
||||
'expenses_income': ['${AppLocalizations.of(context).expensesIncome}'],
|
||||
'accounting_documents': ['${AppLocalizations.of(context).accountingDocuments}'],
|
||||
'chart_of_accounts': ['${AppLocalizations.of(context).chartOfAccounts}'],
|
||||
'opening_balance': ['${AppLocalizations.of(context).openingBalance}'],
|
||||
'warehouses': ['${AppLocalizations.of(context).warehouses}'],
|
||||
'warehouse_transfers': ['${AppLocalizations.of(context).warehouseTransfers}'],
|
||||
'settings': ['${AppLocalizations.of(context).businessSettings}'],
|
||||
'storage': ['${AppLocalizations.of(context).storageSpace}'],
|
||||
'sms': ['${AppLocalizations.of(context).smsPanel}'],
|
||||
'marketplace': ['${AppLocalizations.of(context).marketplace}'],
|
||||
'products': [(AppLocalizations.of(context).products)],
|
||||
'price_lists': [(AppLocalizations.of(context).priceLists)],
|
||||
'categories': [(AppLocalizations.of(context).categories)],
|
||||
'product_attributes': [(AppLocalizations.of(context).productAttributes)],
|
||||
'bank_accounts': [(AppLocalizations.of(context).bankAccounts)],
|
||||
'cash': [(AppLocalizations.of(context).cash)],
|
||||
'petty_cash': [(AppLocalizations.of(context).pettyCash)],
|
||||
'checks': [(AppLocalizations.of(context).checks)],
|
||||
'wallet': [(AppLocalizations.of(context).wallet)],
|
||||
'transfers': [(AppLocalizations.of(context).transfers)],
|
||||
'invoices': [(AppLocalizations.of(context).invoices)],
|
||||
'expenses_income': [(AppLocalizations.of(context).expensesIncome)],
|
||||
'accounting_documents': [(AppLocalizations.of(context).accountingDocuments)],
|
||||
'chart_of_accounts': [(AppLocalizations.of(context).chartOfAccounts)],
|
||||
'opening_balance': [(AppLocalizations.of(context).openingBalance)],
|
||||
'warehouses': [(AppLocalizations.of(context).warehouses)],
|
||||
'warehouse_transfers': [(AppLocalizations.of(context).warehouseTransfers)],
|
||||
'settings': [(AppLocalizations.of(context).businessSettings)],
|
||||
'storage': [(AppLocalizations.of(context).storageSpace)],
|
||||
'sms': [(AppLocalizations.of(context).smsPanel)],
|
||||
'marketplace': [(AppLocalizations.of(context).marketplace)],
|
||||
};
|
||||
|
||||
final hay = (title + ' ' + description).toLowerCase();
|
||||
final hay = ('$title $description').toLowerCase();
|
||||
for (final entry in pairs.entries) {
|
||||
for (final token in entry.value) {
|
||||
if (hay.contains(token.toLowerCase())) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
import '../../models/business_models.dart';
|
||||
import '../../services/business_api_service.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
import '../../widgets/date_input_field.dart';
|
||||
import '../../core/date_utils.dart';
|
||||
|
||||
class NewBusinessPage extends StatefulWidget {
|
||||
const NewBusinessPage({super.key});
|
||||
final CalendarController calendarController;
|
||||
const NewBusinessPage({super.key, required this.calendarController});
|
||||
|
||||
@override
|
||||
State<NewBusinessPage> createState() => _NewBusinessPageState();
|
||||
|
|
@ -16,15 +21,162 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
final BusinessData _businessData = BusinessData();
|
||||
int _currentStep = 0;
|
||||
bool _isLoading = false;
|
||||
int _fiscalTabIndex = 0;
|
||||
late TextEditingController _fiscalTitleController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.calendarController.addListener(_onCalendarChanged);
|
||||
_fiscalTitleController = TextEditingController();
|
||||
// Set default selections for business type and field
|
||||
_businessData.businessType ??= BusinessType.shop;
|
||||
_businessData.businessField ??= BusinessField.commercial;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.calendarController.removeListener(_onCalendarChanged);
|
||||
_pageController.dispose();
|
||||
_fiscalTitleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onCalendarChanged() {
|
||||
if (_businessData.fiscalYears.isEmpty) return;
|
||||
final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
|
||||
if (fiscal.endDate != null) {
|
||||
const autoPrefix = 'سال مالی منتهی به';
|
||||
if (fiscal.title.trim().isEmpty || fiscal.title.trim().startsWith(autoPrefix)) {
|
||||
setState(() {
|
||||
final isJalali = widget.calendarController.isJalali;
|
||||
final endStr = HesabixDateUtils.formatForDisplay(fiscal.endDate, isJalali);
|
||||
fiscal.title = '$autoPrefix $endStr';
|
||||
_fiscalTitleController.text = fiscal.title;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFiscalStep() {
|
||||
if (_businessData.fiscalYears.isEmpty) {
|
||||
_businessData.fiscalYears.add(FiscalYearData(isLast: true));
|
||||
}
|
||||
final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
|
||||
|
||||
String _autoTitle() {
|
||||
final isJalali = widget.calendarController.isJalali;
|
||||
final end = fiscal.endDate;
|
||||
if (end == null) return fiscal.title;
|
||||
final endStr = HesabixDateUtils.formatForDisplay(end, isJalali);
|
||||
return 'سال مالی منتهی به $endStr';
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'سال مالی',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: fiscal.startDate,
|
||||
labelText: 'تاریخ شروع *',
|
||||
lastDate: fiscal.endDate,
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (d) {
|
||||
setState(() {
|
||||
fiscal.startDate = d;
|
||||
if (fiscal.startDate != null) {
|
||||
if (widget.calendarController.isJalali) {
|
||||
final j = Jalali.fromDateTime(fiscal.startDate!);
|
||||
final jNext = Jalali(j.year + 1, j.month, j.day);
|
||||
fiscal.endDate = jNext.toDateTime();
|
||||
} else {
|
||||
final s = fiscal.startDate!;
|
||||
fiscal.endDate = DateTime(s.year + 1, s.month, s.day);
|
||||
}
|
||||
fiscal.title = _autoTitle();
|
||||
_fiscalTitleController.text = fiscal.title;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: fiscal.endDate,
|
||||
labelText: 'تاریخ پایان *',
|
||||
firstDate: fiscal.startDate,
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (d) {
|
||||
setState(() {
|
||||
fiscal.endDate = d;
|
||||
if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) {
|
||||
fiscal.title = _autoTitle();
|
||||
_fiscalTitleController.text = fiscal.title;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _fiscalTitleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'عنوان سال مالی *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
fiscal.title = v;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'پرکردن عنوان، تاریخ شروع و پایان الزامی است.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
void _nextStep() {
|
||||
if (_currentStep < 3) {
|
||||
if (_currentStep < 4) {
|
||||
setState(() {
|
||||
_currentStep++;
|
||||
});
|
||||
|
|
@ -66,6 +218,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
return _businessData.isStep2Valid();
|
||||
case 2:
|
||||
return _businessData.isStep3Valid();
|
||||
case 3:
|
||||
return _businessData.isFiscalStepValid();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
@ -84,6 +238,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
case 2:
|
||||
return t.businessLegalInfo;
|
||||
case 3:
|
||||
return 'سال مالی';
|
||||
case 4:
|
||||
return t.businessConfirmation;
|
||||
default:
|
||||
return '';
|
||||
|
|
@ -122,7 +278,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
context.goNamed('profile_businesses');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
|
@ -170,7 +326,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
children: [
|
||||
// Progress bar
|
||||
Row(
|
||||
children: List.generate(4, (index) {
|
||||
children: List.generate(5, (index) {
|
||||
final isActive = index <= _currentStep;
|
||||
final isCurrent = index == _currentStep;
|
||||
|
||||
|
|
@ -203,7 +359,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
const SizedBox(height: 8),
|
||||
// Progress text
|
||||
Text(
|
||||
'${t.step} ${_currentStep + 1} ${t.ofText} 4',
|
||||
'${t.step} ${_currentStep + 1} ${t.ofText} 5',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -223,7 +379,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
_buildStepIndicator(0, t.businessBasicInfo),
|
||||
_buildStepIndicator(1, t.businessContactInfo),
|
||||
_buildStepIndicator(2, t.businessLegalInfo),
|
||||
_buildStepIndicator(3, t.businessConfirmation),
|
||||
_buildStepIndicator(3, 'سال مالی'),
|
||||
_buildStepIndicator(4, t.businessConfirmation),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -293,6 +450,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
_buildStep1(),
|
||||
_buildStep2(),
|
||||
_buildStep3(),
|
||||
_buildFiscalStep(),
|
||||
_buildStep4(),
|
||||
],
|
||||
),
|
||||
|
|
@ -326,9 +484,9 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildNavigationButton(
|
||||
text: _currentStep < 3 ? t.next : t.createBusiness,
|
||||
icon: _currentStep < 3 ? Icons.arrow_forward_ios : Icons.check,
|
||||
onPressed: _currentStep < 3
|
||||
text: _currentStep < 4 ? t.next : t.createBusiness,
|
||||
icon: _currentStep < 4 ? Icons.arrow_forward_ios : Icons.check,
|
||||
onPressed: _currentStep < 4
|
||||
? (_canGoToNextStep() ? _nextStep : null)
|
||||
: (_isLoading ? null : _submitBusiness),
|
||||
isPrimary: true,
|
||||
|
|
@ -361,7 +519,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
),
|
||||
Row(
|
||||
children: [
|
||||
if (_currentStep < 3) ...[
|
||||
if (_currentStep < 4) ...[
|
||||
_buildNavigationButton(
|
||||
text: t.next,
|
||||
icon: Icons.arrow_forward_ios,
|
||||
|
|
@ -1381,6 +1539,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
_buildSummaryItem(t.city, _businessData.city!),
|
||||
if (_businessData.postalCode?.isNotEmpty == true)
|
||||
_buildSummaryItem(t.postalCode, _businessData.postalCode!),
|
||||
if (_businessData.fiscalYears.isNotEmpty)
|
||||
_buildSummaryItem('سال مالی', _businessData.fiscalYears.first.title),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../models/person_model.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,10 @@ class DataTableConfig<T> {
|
|||
final String? dateRangeField;
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
// Header controls
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final bool showTableIcon;
|
||||
final bool showSearch;
|
||||
final bool showFilters;
|
||||
final bool showPagination;
|
||||
|
|
@ -267,6 +271,9 @@ class DataTableConfig<T> {
|
|||
this.dateRangeField,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.showTableIcon = true,
|
||||
this.showSearch = true,
|
||||
this.showFilters = false,
|
||||
this.showPagination = true,
|
||||
|
|
@ -395,13 +402,27 @@ class DataTableResponse<T> {
|
|||
) {
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
final itemsList = data['items'] as List? ?? [];
|
||||
// Support both old and new pagination shapes
|
||||
final pagination = data['pagination'] as Map<String, dynamic>?;
|
||||
final total = pagination != null
|
||||
? (pagination['total'] as num?)?.toInt() ?? 0
|
||||
: (data['total'] as num?)?.toInt() ?? 0;
|
||||
final page = pagination != null
|
||||
? (pagination['page'] as num?)?.toInt() ?? 1
|
||||
: (data['page'] as num?)?.toInt() ?? 1;
|
||||
final limit = pagination != null
|
||||
? (pagination['per_page'] as num?)?.toInt() ?? 20
|
||||
: (data['limit'] as num?)?.toInt() ?? 20;
|
||||
final totalPages = pagination != null
|
||||
? (pagination['total_pages'] as num?)?.toInt() ?? 0
|
||||
: (data['total_pages'] as num?)?.toInt() ?? 0;
|
||||
|
||||
return DataTableResponse<T>(
|
||||
items: itemsList.map((item) => fromJsonT(item as Map<String, dynamic>)).toList(),
|
||||
total: (data['total'] as num?)?.toInt() ?? 0,
|
||||
page: (data['page'] as num?)?.toInt() ?? 1,
|
||||
limit: (data['limit'] as num?)?.toInt() ?? 20,
|
||||
totalPages: (data['total_pages'] as num?)?.toInt() ?? 0,
|
||||
total: total,
|
||||
page: page,
|
||||
limit: limit,
|
||||
totalPages: totalPages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'helpers/file_saver.dart';
|
||||
// import 'dart:html' as html; // Not available on Linux
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:data_table_2/data_table_2.dart';
|
||||
|
|
@ -561,6 +563,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
params['selected_indices'] = _selectedRows.toList();
|
||||
}
|
||||
|
||||
// Add export columns in current visible order (excluding ActionColumn)
|
||||
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
||||
? _visibleColumns
|
||||
: widget.config.columns;
|
||||
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
|
||||
params['export_columns'] = dataColumnsToShow.map((c) => {
|
||||
'key': c.key,
|
||||
'label': c.label,
|
||||
}).toList();
|
||||
|
||||
// Add custom export parameters if provided
|
||||
if (widget.config.getExportParams != null) {
|
||||
final customParams = widget.config.getExportParams!();
|
||||
|
|
@ -620,17 +632,25 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
}
|
||||
}
|
||||
|
||||
// Platform-specific download functions for Linux
|
||||
// Cross-platform save using conditional FileSaver
|
||||
Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
|
||||
List<int> bytes;
|
||||
if (data is List<int>) {
|
||||
bytes = data;
|
||||
} else if (data is Uint8List) {
|
||||
bytes = data.toList();
|
||||
} else {
|
||||
throw Exception('Unsupported binary data type: ${data.runtimeType}');
|
||||
}
|
||||
await FileSaver.saveBytes(bytes, filename);
|
||||
}
|
||||
|
||||
Future<void> _downloadPdf(dynamic data, String filename) async {
|
||||
// For Linux desktop, we'll save to Downloads folder
|
||||
debugPrint('Download PDF: $filename (Linux desktop - save to Downloads folder)');
|
||||
// TODO: Implement proper file saving for Linux
|
||||
await _saveBytesToDownloads(data, filename);
|
||||
}
|
||||
|
||||
Future<void> _downloadExcel(dynamic data, String filename) async {
|
||||
// For Linux desktop, we'll save to Downloads folder
|
||||
debugPrint('Download Excel: $filename (Linux desktop - save to Downloads folder)');
|
||||
// TODO: Implement proper file saving for Linux
|
||||
await _saveBytesToDownloads(data, filename);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -641,15 +661,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
|
||||
return Card(
|
||||
elevation: widget.config.boxShadow != null ? 2 : 0,
|
||||
shape: widget.config.borderRadius != null
|
||||
? RoundedRectangleBorder(borderRadius: widget.config.borderRadius!)
|
||||
: null,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
padding: widget.config.padding ?? const EdgeInsets.all(16),
|
||||
margin: widget.config.margin,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.config.backgroundColor,
|
||||
borderRadius: widget.config.borderRadius,
|
||||
borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
|
||||
border: widget.config.showBorder
|
||||
? Border.all(
|
||||
color: widget.config.borderColor ?? theme.dividerColor,
|
||||
|
|
@ -719,6 +740,21 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
Widget _buildHeader(AppLocalizations t, ThemeData theme) {
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.config.showBackButton) ...[
|
||||
Tooltip(
|
||||
message: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
child: IconButton(
|
||||
onPressed: widget.config.onBack ?? () {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (widget.config.showTableIcon) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -732,6 +768,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Text(
|
||||
widget.config.title!,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
|
|
|
|||
|
|
@ -105,9 +105,14 @@ class ColumnSettingsService {
|
|||
}
|
||||
|
||||
// Ensure all default columns are present in visible columns
|
||||
// If new columns are added (not in user settings), include them by default
|
||||
final visibleColumns = <String>[];
|
||||
final userVisible = Set<String>.from(userSettings.visibleColumns);
|
||||
for (final key in defaultColumnKeys) {
|
||||
if (userSettings.visibleColumns.contains(key)) {
|
||||
if (userVisible.contains(key)) {
|
||||
visibleColumns.add(key);
|
||||
} else {
|
||||
// New column introduced → show by default
|
||||
visibleColumns.add(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -117,15 +122,13 @@ class ColumnSettingsService {
|
|||
visibleColumns.add(defaultColumnKeys.first);
|
||||
}
|
||||
|
||||
// Ensure all visible columns are in the correct order
|
||||
// Build columnOrder: keep user's order for known columns, append new ones at the end
|
||||
final columnOrder = <String>[];
|
||||
for (final key in userSettings.columnOrder) {
|
||||
if (visibleColumns.contains(key)) {
|
||||
columnOrder.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any missing visible columns to the end
|
||||
for (final key in visibleColumns) {
|
||||
if (!columnOrder.contains(key)) {
|
||||
columnOrder.add(key);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
// Conditional export of platform-specific implementations
|
||||
export 'file_saver_io.dart' if (dart.library.html) 'file_saver_web.dart';
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import 'dart:io';
|
||||
|
||||
class FileSaver {
|
||||
static Future<String?> saveBytes(List<int> bytes, String filename) async {
|
||||
final homeDir = Platform.environment['HOME'] ?? Directory.current.path;
|
||||
final downloadsDir = Directory('$homeDir/Downloads');
|
||||
if (!await downloadsDir.exists()) {
|
||||
await downloadsDir.create(recursive: true);
|
||||
}
|
||||
final file = File('${downloadsDir.path}/$filename');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import 'dart:html' as html;
|
||||
|
||||
class FileSaver {
|
||||
static Future<String?> saveBytes(List<int> bytes, String filename) async {
|
||||
final blob = html.Blob([bytes]);
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', filename)
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -31,6 +31,13 @@ class _JalaliDatePickerState extends State<JalaliDatePicker> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_selectedDate = widget.initialDate ?? DateTime.now();
|
||||
// Clamp initial within range if provided
|
||||
if (widget.firstDate != null && _selectedDate.isBefore(widget.firstDate!)) {
|
||||
_selectedDate = widget.firstDate!;
|
||||
}
|
||||
if (widget.lastDate != null && _selectedDate.isAfter(widget.lastDate!)) {
|
||||
_selectedDate = widget.lastDate!;
|
||||
}
|
||||
_selectedJalali = Jalali.fromDateTime(_selectedDate);
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +225,10 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
|
|||
}
|
||||
|
||||
void _selectDate(Jalali date) {
|
||||
// Enforce range limits
|
||||
if (date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
|
|
@ -305,18 +316,23 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
|
|||
date.month == Jalali.now().month &&
|
||||
date.day == Jalali.now().day;
|
||||
|
||||
final isDisabled = date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0;
|
||||
return GestureDetector(
|
||||
onTap: () => _selectDate(date),
|
||||
onTap: isDisabled ? null : () => _selectDate(date),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
color: isDisabled
|
||||
? theme.disabledColor.withValues(alpha: 0.1)
|
||||
: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: isToday
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isToday && !isSelected
|
||||
border: isDisabled
|
||||
? Border.all(color: theme.disabledColor.withValues(alpha: 0.3), width: 1)
|
||||
: isToday && !isSelected
|
||||
? Border.all(color: theme.colorScheme.primary, width: 1)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -324,7 +340,9 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
|
|||
child: Text(
|
||||
day.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isSelected
|
||||
color: isDisabled
|
||||
? theme.disabledColor
|
||||
: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: isToday
|
||||
? theme.colorScheme.primary
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../models/person_model.dart';
|
||||
import '../../services/person_service.dart';
|
||||
|
||||
|
|
@ -51,6 +52,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
final _faxController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _websiteController = TextEditingController();
|
||||
final _shareCountController = TextEditingController();
|
||||
// Commission controllers & state
|
||||
final _commissionSalePercentController = TextEditingController();
|
||||
final _commissionSalesReturnPercentController = TextEditingController();
|
||||
final _commissionSalesAmountController = TextEditingController();
|
||||
final _commissionSalesReturnAmountController = TextEditingController();
|
||||
bool _commissionExcludeDiscounts = false;
|
||||
bool _commissionExcludeAdditionsDeductions = false;
|
||||
bool _commissionPostInInvoiceDocument = false;
|
||||
|
||||
PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
|
||||
final Set<PersonType> _selectedPersonTypes = <PersonType>{};
|
||||
|
|
@ -96,6 +106,26 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
|
||||
_isActive = person.isActive;
|
||||
_bankAccounts = List.from(person.bankAccounts);
|
||||
// مقدار اولیه سهام
|
||||
if (person.personTypes.contains(PersonType.shareholder) && person.shareCount != null) {
|
||||
_shareCountController.text = person.shareCount!.toString();
|
||||
}
|
||||
// مقدار اولیه پورسانت
|
||||
if (person.commissionSalePercent != null) {
|
||||
_commissionSalePercentController.text = person.commissionSalePercent!.toString();
|
||||
}
|
||||
if (person.commissionSalesReturnPercent != null) {
|
||||
_commissionSalesReturnPercentController.text = person.commissionSalesReturnPercent!.toString();
|
||||
}
|
||||
if (person.commissionSalesAmount != null) {
|
||||
_commissionSalesAmountController.text = person.commissionSalesAmount!.toString();
|
||||
}
|
||||
if (person.commissionSalesReturnAmount != null) {
|
||||
_commissionSalesReturnAmountController.text = person.commissionSalesReturnAmount!.toString();
|
||||
}
|
||||
_commissionExcludeDiscounts = person.commissionExcludeDiscounts;
|
||||
_commissionExcludeAdditionsDeductions = person.commissionExcludeAdditionsDeductions;
|
||||
_commissionPostInInvoiceDocument = person.commissionPostInInvoiceDocument;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +150,11 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
_faxController.dispose();
|
||||
_emailController.dispose();
|
||||
_websiteController.dispose();
|
||||
_shareCountController.dispose();
|
||||
_commissionSalePercentController.dispose();
|
||||
_commissionSalesReturnPercentController.dispose();
|
||||
_commissionSalesAmountController.dispose();
|
||||
_commissionSalesReturnAmountController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +171,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
final personData = PersonCreateRequest(
|
||||
code: _autoGenerateCode
|
||||
? null
|
||||
: (int.tryParse(_codeController.text.trim()) ?? null),
|
||||
: (int.tryParse(_codeController.text.trim())),
|
||||
aliasName: _aliasNameController.text.trim(),
|
||||
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
||||
|
|
@ -157,6 +192,31 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
|
||||
bankAccounts: _bankAccounts,
|
||||
shareCount: _selectedPersonTypes.contains(PersonType.shareholder)
|
||||
? int.tryParse(_shareCountController.text.trim())
|
||||
: null,
|
||||
// commission fields only if marketer or seller
|
||||
commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalePercentController.text.trim())
|
||||
: null,
|
||||
commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesReturnPercentController.text.trim())
|
||||
: null,
|
||||
commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesAmountController.text.trim())
|
||||
: null,
|
||||
commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesReturnAmountController.text.trim())
|
||||
: null,
|
||||
commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionExcludeDiscounts
|
||||
: null,
|
||||
commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionExcludeAdditionsDeductions
|
||||
: null,
|
||||
commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionPostInInvoiceDocument
|
||||
: null,
|
||||
);
|
||||
|
||||
await _personService.createPerson(
|
||||
|
|
@ -166,7 +226,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
} else {
|
||||
// Update existing person
|
||||
final personData = PersonUpdateRequest(
|
||||
code: (int.tryParse(_codeController.text.trim()) ?? null),
|
||||
code: (int.tryParse(_codeController.text.trim())),
|
||||
aliasName: _aliasNameController.text.trim(),
|
||||
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
||||
|
|
@ -188,6 +248,30 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
|
||||
isActive: _isActive,
|
||||
shareCount: _selectedPersonTypes.contains(PersonType.shareholder)
|
||||
? int.tryParse(_shareCountController.text.trim())
|
||||
: null,
|
||||
commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalePercentController.text.trim())
|
||||
: null,
|
||||
commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesReturnPercentController.text.trim())
|
||||
: null,
|
||||
commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesAmountController.text.trim())
|
||||
: null,
|
||||
commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? double.tryParse(_commissionSalesReturnAmountController.text.trim())
|
||||
: null,
|
||||
commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionExcludeDiscounts
|
||||
: null,
|
||||
commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionExcludeAdditionsDeductions
|
||||
: null,
|
||||
commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
|
||||
? _commissionPostInInvoiceDocument
|
||||
: null,
|
||||
);
|
||||
|
||||
await _personService.updatePerson(
|
||||
|
|
@ -288,23 +372,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: DefaultTabController(
|
||||
length: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
child: Builder(builder: (context) {
|
||||
final hasCommissionTab = _selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller);
|
||||
final tabs = <Tab>[
|
||||
Tab(text: t.personBasicInfo),
|
||||
Tab(text: t.personEconomicInfo),
|
||||
Tab(text: t.personContactInfo),
|
||||
Tab(text: t.personBankInfo),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
];
|
||||
final views = <Widget>[
|
||||
SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
|
|
@ -329,12 +405,35 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
child: _buildBankAccountsSection(t),
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
if (hasCommissionTab) {
|
||||
tabs.add(const Tab(text: 'پورسانت'));
|
||||
views.add(
|
||||
SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: _buildCommissionTab(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return DefaultTabController(
|
||||
key: ValueKey(tabs.length),
|
||||
length: tabs.length,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
tabs: tabs,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: TabBarView(children: views),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -367,6 +466,128 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCommissionTab() {
|
||||
final isMarketer = _selectedPersonTypes.contains(PersonType.marketer);
|
||||
final isSeller = _selectedPersonTypes.contains(PersonType.seller);
|
||||
if (!isMarketer && !isSeller) {
|
||||
return Center(
|
||||
child: Text('این بخش فقط برای بازاریاب/فروشنده نمایش داده میشود'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _commissionSalePercentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'درصد از فروش',
|
||||
suffixText: '%',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
|
||||
validator: (v) {
|
||||
if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
|
||||
final num? val = num.tryParse(v);
|
||||
if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _commissionSalesReturnPercentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'درصد از برگشت از فروش',
|
||||
suffixText: '%',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
|
||||
validator: (v) {
|
||||
if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
|
||||
final num? val = num.tryParse(v);
|
||||
if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _commissionSalesAmountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'مبلغ فروش',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
|
||||
validator: (v) {
|
||||
if (v != null && v.isNotEmpty) {
|
||||
final num? val = num.tryParse(v);
|
||||
if (val == null || val < 0) return 'باید عدد مثبت باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _commissionSalesReturnAmountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'مبلغ برگشت از فروش',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
|
||||
validator: (v) {
|
||||
if (v != null && v.isNotEmpty) {
|
||||
final num? val = num.tryParse(v);
|
||||
if (val == null || val < 0) return 'باید عدد مثبت باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text('عدم محاسبه تخفیف'),
|
||||
value: _commissionExcludeDiscounts,
|
||||
onChanged: (v) { setState(() { _commissionExcludeDiscounts = v; }); },
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text('عدم محاسبه اضافات و کسورات فاکتور'),
|
||||
value: _commissionExcludeAdditionsDeductions,
|
||||
onChanged: (v) { setState(() { _commissionExcludeAdditionsDeductions = v; }); },
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: const Text('ثبت پورسانت در سند حسابداری فاکتور'),
|
||||
value: _commissionPostInInvoiceDocument,
|
||||
onChanged: (v) { setState(() { _commissionPostInInvoiceDocument = v; }); },
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Text(
|
||||
title,
|
||||
|
|
@ -457,6 +678,34 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_selectedPersonTypes.contains(PersonType.shareholder))
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _shareCountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'تعداد سهام',
|
||||
hintText: 'عدد صحیح بدون اعشار',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (_selectedPersonTypes.contains(PersonType.shareholder)) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'برای سهامدار، تعداد سهام الزامی است';
|
||||
}
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed == null || parsed <= 0) {
|
||||
return 'تعداد سهام باید عدد صحیح بزرگتر از صفر باشد';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../core/auth_store.dart';
|
||||
|
||||
class UrlTracker extends StatefulWidget {
|
||||
|
|
@ -29,7 +28,7 @@ class _UrlTrackerState extends State<UrlTracker> {
|
|||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
final currentUrl = GoRouterState.of(context).uri.path;
|
||||
final currentUrl = Uri.base.path;
|
||||
if (currentUrl != _lastTrackedUrl &&
|
||||
currentUrl.isNotEmpty &&
|
||||
currentUrl != '/' &&
|
||||
|
|
|
|||
Loading…
Reference in a new issue