progress in cheques
This commit is contained in:
parent
9701fa31b2
commit
b7a860e3e5
|
|
@ -8,6 +8,7 @@ from adapters.db.session import get_db
|
|||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access
|
||||
from app.core.responses import success_response, ApiError, format_datetime_fields
|
||||
from adapters.api.v1.schemas import QueryInfo, PaginatedResponse
|
||||
from adapters.api.v1.schema_models.warehouse import (
|
||||
WarehouseCreateRequest,
|
||||
WarehouseUpdateRequest,
|
||||
|
|
@ -18,6 +19,7 @@ from app.services.warehouse_service import (
|
|||
get_warehouse,
|
||||
update_warehouse,
|
||||
delete_warehouse,
|
||||
query_warehouses,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -103,3 +105,33 @@ def delete_warehouse_endpoint(
|
|||
return success_response({"deleted": ok}, request)
|
||||
|
||||
|
||||
|
||||
@router.post("/business/{business_id}/query")
|
||||
def query_warehouses_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
payload: QueryInfo,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# دسترسی به کسبوکار و مجوز خواندن انبار
|
||||
if not ctx.can_access_business(int(business_id)):
|
||||
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
|
||||
if not ctx.can_read_section("inventory"):
|
||||
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
||||
result = query_warehouses(db, business_id, payload)
|
||||
# تطبیق خروجی با ساختار DataTableResponse (items + pagination)
|
||||
data = {
|
||||
"items": format_datetime_fields(result["items"], request),
|
||||
"pagination": {
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["limit"],
|
||||
"total_pages": result["total_pages"],
|
||||
"has_next": result["page"] < result["total_pages"],
|
||||
"has_prev": result["page"] > 1,
|
||||
},
|
||||
"query_info": payload.model_dump(),
|
||||
}
|
||||
return success_response(data=data, request=request)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ from adapters.db.session import Base
|
|||
|
||||
|
||||
class CheckType(str, Enum):
|
||||
RECEIVED = "received"
|
||||
TRANSFERRED = "transferred"
|
||||
RECEIVED = "RECEIVED"
|
||||
TRANSFERRED = "TRANSFERRED"
|
||||
|
||||
|
||||
class Check(Base):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from functools import wraps
|
||||
from typing import Callable, Any
|
||||
from typing import Callable, Any, get_type_hints
|
||||
import inspect
|
||||
|
||||
from fastapi import Depends
|
||||
|
|
@ -131,6 +131,12 @@ def require_business_access(business_id_param: str = "business_id"):
|
|||
return result
|
||||
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
||||
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
|
||||
# Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations
|
||||
try:
|
||||
wrapper.__annotations__ = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
# Fallback to original annotations (may be string-based) if evaluation fails
|
||||
wrapper.__annotations__ = getattr(func, "__annotations__", {})
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ def _parse_iso(dt: str) -> datetime:
|
|||
|
||||
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ctype = str(data.get('type', '')).lower()
|
||||
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||
if ctype not in ("received", "transferred"):
|
||||
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||
|
||||
person_id = data.get('person_id')
|
||||
if ctype == CheckType.RECEIVED.value and not person_id:
|
||||
if ctype == "received" and not person_id:
|
||||
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
|
||||
|
||||
issue_date = _parse_iso(str(data.get('issue_date')))
|
||||
|
|
@ -63,7 +63,7 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st
|
|||
|
||||
obj = Check(
|
||||
business_id=business_id,
|
||||
type=CheckType(ctype),
|
||||
type=CheckType[ctype.upper()],
|
||||
person_id=int(person_id) if person_id else None,
|
||||
issue_date=issue_date,
|
||||
due_date=due_date,
|
||||
|
|
@ -93,9 +93,9 @@ def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[D
|
|||
|
||||
if 'type' in data and data['type'] is not None:
|
||||
ctype = str(data['type']).lower()
|
||||
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||
if ctype not in ("received", "transferred"):
|
||||
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||
obj.type = CheckType(ctype)
|
||||
obj.type = CheckType[ctype.upper()]
|
||||
|
||||
if 'person_id' in data:
|
||||
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
|
||||
|
|
@ -192,7 +192,11 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st
|
|||
if not prop or not op:
|
||||
continue
|
||||
if prop == 'type' and op == '=':
|
||||
q = q.filter(Check.type == val)
|
||||
try:
|
||||
enum_val = CheckType[str(val).upper()]
|
||||
q = q.filter(Check.type == enum_val)
|
||||
except Exception:
|
||||
pass
|
||||
elif prop == 'currency' and op == '=':
|
||||
try:
|
||||
q = q.filter(Check.currency_id == int(val))
|
||||
|
|
@ -267,7 +271,7 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]
|
|||
return {
|
||||
"id": obj.id,
|
||||
"business_id": obj.business_id,
|
||||
"type": obj.type.value,
|
||||
"type": obj.type.name.lower(),
|
||||
"person_id": obj.person_id,
|
||||
"person_name": person_name,
|
||||
"issue_date": obj.issue_date.isoformat(),
|
||||
|
|
|
|||
|
|
@ -68,12 +68,24 @@ def _iter_product_movements(
|
|||
"""
|
||||
if not product_ids:
|
||||
return []
|
||||
# فقط کالاهای با کنترل موجودی را لحاظ کن
|
||||
tracked_ids: List[int] = [
|
||||
int(pid)
|
||||
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
|
||||
Product.business_id == business_id,
|
||||
Product.id.in_(list({int(pid) for pid in product_ids})),
|
||||
).all()
|
||||
if bool(tracked)
|
||||
]
|
||||
if not tracked_ids:
|
||||
return []
|
||||
|
||||
q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter(
|
||||
and_(
|
||||
Document.business_id == business_id,
|
||||
Document.is_proforma == False, # noqa: E712
|
||||
Document.document_date <= up_to_date,
|
||||
DocumentLine.product_id.in_(list({int(pid) for pid in product_ids})),
|
||||
DocumentLine.product_id.in_(tracked_ids),
|
||||
)
|
||||
)
|
||||
if exclude_document_id is not None:
|
||||
|
|
@ -353,6 +365,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
|
|||
total = Decimal(0)
|
||||
for line in lines:
|
||||
info = line.get("extra_info") or {}
|
||||
# فقط برای کالاهای دارای کنترل موجودی
|
||||
if not bool(info.get("inventory_tracked")):
|
||||
continue
|
||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||
if info.get("cogs_amount") is not None:
|
||||
total += Decimal(str(info.get("cogs_amount")))
|
||||
|
|
@ -486,20 +501,45 @@ def create_invoice(
|
|||
if mv == "out":
|
||||
outgoing_lines.append(ln)
|
||||
|
||||
# Ensure stock sufficiency for outgoing
|
||||
if outgoing_lines:
|
||||
_ensure_stock_sufficient(db, business_id, document_date, outgoing_lines)
|
||||
# Resolve inventory tracking per product and annotate lines
|
||||
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
|
||||
track_map: Dict[int, bool] = {}
|
||||
if all_product_ids:
|
||||
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
|
||||
Product.business_id == business_id,
|
||||
Product.id.in_(all_product_ids),
|
||||
).all():
|
||||
track_map[int(pid)] = bool(tracked)
|
||||
|
||||
# Costing method
|
||||
for ln in lines_input:
|
||||
pid = ln.get("product_id")
|
||||
if not pid:
|
||||
continue
|
||||
info = dict(ln.get("extra_info") or {})
|
||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||
ln["extra_info"] = info
|
||||
|
||||
# Filter outgoing lines to only inventory-tracked products for stock checks
|
||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in outgoing_lines:
|
||||
pid = ln.get("product_id")
|
||||
if pid and track_map.get(int(pid)):
|
||||
tracked_outgoing_lines.append(ln)
|
||||
|
||||
# Ensure stock sufficiency for outgoing (only for tracked products)
|
||||
if tracked_outgoing_lines:
|
||||
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
|
||||
|
||||
# Costing method (only for tracked products)
|
||||
costing_method = _get_costing_method(data)
|
||||
if costing_method == "fifo" and outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, outgoing_lines)
|
||||
# annotate lines with cogs_amount in the same order as outgoing_lines
|
||||
if costing_method == "fifo" and tracked_outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
|
||||
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
|
||||
i = 0
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out":
|
||||
if mv == "out" and info.get("inventory_tracked"):
|
||||
amt = fifo_costs[i]
|
||||
i += 1
|
||||
info = dict(info)
|
||||
|
|
@ -901,18 +941,41 @@ def update_invoice(
|
|||
if mv == "out":
|
||||
outgoing_lines.append(ln)
|
||||
|
||||
if outgoing_lines:
|
||||
_ensure_stock_sufficient(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id)
|
||||
# Resolve and annotate inventory tracking for all lines
|
||||
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
|
||||
track_map: Dict[int, bool] = {}
|
||||
if all_product_ids:
|
||||
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
|
||||
Product.business_id == document.business_id,
|
||||
Product.id.in_(all_product_ids),
|
||||
).all():
|
||||
track_map[int(pid)] = bool(tracked)
|
||||
for ln in lines_input:
|
||||
pid = ln.get("product_id")
|
||||
if not pid:
|
||||
continue
|
||||
info = dict(ln.get("extra_info") or {})
|
||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||
ln["extra_info"] = info
|
||||
|
||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in outgoing_lines:
|
||||
pid = ln.get("product_id")
|
||||
if pid and track_map.get(int(pid)):
|
||||
tracked_outgoing_lines.append(ln)
|
||||
|
||||
if tracked_outgoing_lines:
|
||||
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||
|
||||
header_for_costing = data if data else {"extra_info": document.extra_info}
|
||||
costing_method = _get_costing_method(header_for_costing)
|
||||
if costing_method == "fifo" and outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id)
|
||||
if costing_method == "fifo" and tracked_outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||
i = 0
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out":
|
||||
if mv == "out" and info.get("inventory_tracked"):
|
||||
amt = fifo_costs[i]
|
||||
i += 1
|
||||
info = dict(info)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from app.core.responses import ApiError
|
|||
from adapters.db.models.warehouse import Warehouse
|
||||
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
||||
from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest
|
||||
from adapters.api.v1.schemas import QueryInfo, FilterItem
|
||||
from app.services.query_service import QueryService
|
||||
|
||||
|
||||
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
|
||||
|
|
@ -88,3 +90,36 @@ def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
|
|||
return repo.delete(warehouse_id)
|
||||
|
||||
|
||||
|
||||
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
|
||||
"""Query warehouses with filters/search/sorting/pagination scoped to business."""
|
||||
# Ensure business scoping via filters
|
||||
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
|
||||
merged_filters = [base_filter]
|
||||
if query_info.filters:
|
||||
merged_filters.extend(query_info.filters)
|
||||
|
||||
effective_query = QueryInfo(
|
||||
sort_by=query_info.sort_by,
|
||||
sort_desc=query_info.sort_desc,
|
||||
take=query_info.take,
|
||||
skip=query_info.skip,
|
||||
search=query_info.search,
|
||||
search_fields=query_info.search_fields,
|
||||
filters=merged_filters,
|
||||
)
|
||||
|
||||
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
|
||||
items = [_to_dict(w) for w in results]
|
||||
limit = max(1, effective_query.take)
|
||||
page = (effective_query.skip // limit) + 1
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ app/core/responses.py
|
|||
app/core/security.py
|
||||
app/core/settings.py
|
||||
app/core/smart_normalizer.py
|
||||
app/services/account_service.py
|
||||
app/services/api_key_service.py
|
||||
app/services/auth_service.py
|
||||
app/services/bank_account_service.py
|
||||
|
|
@ -144,6 +145,7 @@ app/services/document_service.py
|
|||
app/services/email_service.py
|
||||
app/services/expense_income_service.py
|
||||
app/services/file_storage_service.py
|
||||
app/services/invoice_service.py
|
||||
app/services/person_service.py
|
||||
app/services/petty_cash_service.py
|
||||
app/services/price_list_service.py
|
||||
|
|
|
|||
47
hesabixAPI/scripts/migrate_checks_enum_uppercase.py
Normal file
47
hesabixAPI/scripts/migrate_checks_enum_uppercase.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from sqlalchemy import text
|
||||
from adapters.db.session import SessionLocal
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Migrate MySQL ENUM values of checks.type to uppercase only.
|
||||
|
||||
Steps:
|
||||
1) Allow both lowercase and uppercase temporarily
|
||||
2) Update existing rows to uppercase
|
||||
3) Restrict enum to uppercase values only
|
||||
Safe to re-run.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
# Case-insensitive collations in MySQL prevent having both 'received' and 'RECEIVED'
|
||||
# So we migrate via temporary placeholders.
|
||||
|
||||
# 1) Add placeholders alongside existing lowercase values
|
||||
db.execute(text(
|
||||
"ALTER TABLE checks MODIFY COLUMN type ENUM('received','transferred','TMP_R','TMP_T') NOT NULL"
|
||||
))
|
||||
|
||||
# 2) Move existing rows to placeholders
|
||||
db.execute(text("UPDATE checks SET type='TMP_R' WHERE type='received'"))
|
||||
db.execute(text("UPDATE checks SET type='TMP_T' WHERE type='transferred'"))
|
||||
|
||||
# 3) Switch enum to uppercase + placeholders
|
||||
db.execute(text(
|
||||
"ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED','TMP_R','TMP_T') NOT NULL"
|
||||
))
|
||||
|
||||
# 4) Move placeholders to uppercase values
|
||||
db.execute(text("UPDATE checks SET type='RECEIVED' WHERE type='TMP_R'"))
|
||||
db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE type='TMP_T'"))
|
||||
|
||||
# 5) Drop placeholders
|
||||
db.execute(text(
|
||||
"ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED') NOT NULL"
|
||||
))
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
|
||||
20
hesabixAPI/scripts/normalize_checks_type_uppercase.py
Normal file
20
hesabixAPI/scripts/normalize_checks_type_uppercase.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from sqlalchemy import text
|
||||
from adapters.db.session import SessionLocal
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Normalize existing checks.type values to uppercase to match Enum.
|
||||
|
||||
Converts 'received' -> 'RECEIVED' and 'transferred' -> 'TRANSFERRED'.
|
||||
Safe to run multiple times.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("UPDATE checks SET type='RECEIVED' WHERE LOWER(type)='received'"))
|
||||
db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE LOWER(type)='transferred'"))
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
|
||||
|
|
@ -535,6 +535,7 @@ class _MyAppState extends State<MyApp> {
|
|||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: AccountsPage(
|
||||
businessId: int.parse(state.pathParameters['business_id']!),
|
||||
authStore: _authStore!,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
import 'package:hesabix_ui/core/auth_store.dart';
|
||||
|
||||
class AccountNode {
|
||||
final String id;
|
||||
|
|
@ -43,7 +44,8 @@ class _VisibleNode {
|
|||
|
||||
class AccountsPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
const AccountsPage({super.key, required this.businessId});
|
||||
final AuthStore authStore;
|
||||
const AccountsPage({super.key, required this.businessId, required this.authStore});
|
||||
|
||||
@override
|
||||
State<AccountsPage> createState() => _AccountsPageState();
|
||||
|
|
@ -163,8 +165,12 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
data: payload,
|
||||
);
|
||||
if (context.mounted) Navigator.of(ctx).pop(true);
|
||||
} catch (_) {
|
||||
// نمایش خطا میتواند بعداً اضافه شود
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در ایجاد حساب: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(t.add),
|
||||
|
|
@ -206,6 +212,105 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> _openEditDialog(AccountNode node) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final codeCtrl = TextEditingController(text: node.code);
|
||||
final nameCtrl = TextEditingController(text: node.name);
|
||||
final typeCtrl = TextEditingController(text: node.accountType ?? '');
|
||||
final parents = _flattenNodes();
|
||||
String? selectedParentId;
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(t.edit),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)),
|
||||
TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)),
|
||||
TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)),
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedParentId,
|
||||
items: [
|
||||
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
|
||||
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
|
||||
],
|
||||
onChanged: (v) { selectedParentId = v; },
|
||||
decoration: const InputDecoration(labelText: 'حساب والد'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameCtrl.text.trim();
|
||||
final code = codeCtrl.text.trim();
|
||||
final atype = typeCtrl.text.trim();
|
||||
if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
|
||||
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
|
||||
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
|
||||
final pid = int.tryParse(selectedParentId!);
|
||||
if (pid != null) payload["parent_id"] = pid;
|
||||
}
|
||||
try {
|
||||
final id = int.tryParse(node.id);
|
||||
if (id == null) return;
|
||||
final api = ApiClient();
|
||||
await api.put('/api/v1/accounts/account/$id', data: payload);
|
||||
if (context.mounted) Navigator.of(ctx).pop(true);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در ویرایش حساب: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(t.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == true) {
|
||||
await _fetch();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AccountNode node) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final id = int.tryParse(node.id);
|
||||
if (id == null) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.delete),
|
||||
content: const Text('آیا مطمئن هستید؟'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
|
||||
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
try {
|
||||
final api = ApiClient();
|
||||
await api.delete('/api/v1/accounts/account/$id');
|
||||
await _fetch();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در حذف حساب: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedAccountType(AppLocalizations t, String? value) {
|
||||
if (value == null || value.isEmpty) return '-';
|
||||
final ln = t.localeName;
|
||||
|
|
@ -315,6 +420,20 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
||||
Expanded(flex: 5, child: Text(node.name)),
|
||||
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: PopupMenuButton<String>(
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _openEditDialog(node);
|
||||
if (v == 'delete') _confirmDelete(node);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')),
|
||||
const PopupMenuItem<String>(value: 'delete', child: Text('حذف')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -325,10 +444,12 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: widget.authStore.canWriteSection('accounting')
|
||||
? FloatingActionButton(
|
||||
onPressed: _openCreateDialog,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../services/warehouse_service.dart';
|
||||
import '../../models/warehouse_model.dart';
|
||||
import '../../widgets/data_table/data_table_widget.dart';
|
||||
import '../../widgets/data_table/data_table_config.dart';
|
||||
|
||||
class WarehousesPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -12,68 +14,80 @@ class WarehousesPage extends StatefulWidget {
|
|||
|
||||
class _WarehousesPageState extends State<WarehousesPage> {
|
||||
final WarehouseService _service = WarehouseService();
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<Warehouse> _items = const <Warehouse>[];
|
||||
final GlobalKey _tableKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
void _refreshTable() {
|
||||
try {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final items = await _service.listWarehouses(businessId: widget.businessId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
final current = _tableKey.currentState as dynamic;
|
||||
current?.refresh();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('مدیریت انبارها')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
body: DataTableWidget<Warehouse>(
|
||||
key: _tableKey,
|
||||
fromJson: (m) => Warehouse.fromJson(m),
|
||||
config: DataTableConfig<Warehouse>(
|
||||
endpoint: '/api/v1/warehouses/business/${widget.businessId}/query',
|
||||
title: 'فهرست انبارها',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
showTableIcon: false,
|
||||
showSearch: true,
|
||||
showPagination: true,
|
||||
showRowNumbers: true,
|
||||
enableSorting: true,
|
||||
searchFields: const ['code', 'name', 'description'],
|
||||
customHeaderActions: [
|
||||
Tooltip(
|
||||
message: 'افزودن انبار',
|
||||
child: IconButton(
|
||||
onPressed: _showCreateDialog,
|
||||
child: const Icon(Icons.add),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, idx) {
|
||||
final w = _items[idx];
|
||||
return ListTile(
|
||||
leading: Icon(w.isDefault ? Icons.star : Icons.store, color: w.isDefault ? Colors.orange : null),
|
||||
title: Text('${w.code} - ${w.name}'),
|
||||
subtitle: Text(w.description ?? ''),
|
||||
onTap: () => _showEditDialog(w),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _delete(w),
|
||||
),
|
||||
);
|
||||
],
|
||||
columns: [
|
||||
ActionColumn('actions', 'عملیات', actions: [
|
||||
DataTableAction(
|
||||
icon: Icons.edit_outlined,
|
||||
label: 'ویرایش',
|
||||
onTap: (item) {
|
||||
if (item is Warehouse) _showEditDialog(item);
|
||||
},
|
||||
),
|
||||
DataTableAction(
|
||||
icon: Icons.delete_outline,
|
||||
label: 'حذف',
|
||||
onTap: (item) {
|
||||
if (item is Warehouse) _delete(item);
|
||||
},
|
||||
isDestructive: true,
|
||||
),
|
||||
]),
|
||||
TextColumn('code', 'کد',
|
||||
formatter: (item) => (item as Warehouse).code,
|
||||
width: ColumnWidth.small),
|
||||
TextColumn('name', 'نام',
|
||||
formatter: (item) => (item as Warehouse).name,
|
||||
width: ColumnWidth.medium),
|
||||
TextColumn('description', 'توضیحات',
|
||||
formatter: (item) => (item as Warehouse).description ?? '',
|
||||
width: ColumnWidth.large,
|
||||
searchable: true),
|
||||
TextColumn('is_default', 'پیشفرض',
|
||||
formatter: (item) => (item as Warehouse).isDefault ? 'بله' : 'خیر',
|
||||
sortable: false,
|
||||
searchable: false,
|
||||
width: ColumnWidth.small),
|
||||
DateColumn('created_at', 'ایجاد',
|
||||
formatter: (item) => (item as Warehouse).createdAt?.toIso8601String() ?? '',
|
||||
showTime: false,
|
||||
width: ColumnWidth.small),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -109,7 +123,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
|||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
final created = await _service.createWarehouse(
|
||||
await _service.createWarehouse(
|
||||
businessId: widget.businessId,
|
||||
payload: {
|
||||
'code': codeCtrl.text.trim(),
|
||||
|
|
@ -118,9 +132,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
|||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = [created, ..._items];
|
||||
});
|
||||
_refreshTable();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||
|
|
@ -158,7 +170,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
|||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
final updated = await _service.updateWarehouse(
|
||||
await _service.updateWarehouse(
|
||||
businessId: widget.businessId,
|
||||
warehouseId: w.id!,
|
||||
payload: {
|
||||
|
|
@ -168,9 +180,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
|||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
|
||||
});
|
||||
_refreshTable();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||
|
|
@ -194,9 +204,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
|||
final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!);
|
||||
if (!mounted) return;
|
||||
if (deleted) {
|
||||
setState(() {
|
||||
_items = _items.where((e) => e.id != w.id).toList();
|
||||
});
|
||||
_refreshTable();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue