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.auth_dependency import get_current_user, AuthContext
|
||||||
from app.core.permissions import require_business_access
|
from app.core.permissions import require_business_access
|
||||||
from app.core.responses import success_response, ApiError, format_datetime_fields
|
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 (
|
from adapters.api.v1.schema_models.warehouse import (
|
||||||
WarehouseCreateRequest,
|
WarehouseCreateRequest,
|
||||||
WarehouseUpdateRequest,
|
WarehouseUpdateRequest,
|
||||||
|
|
@ -18,6 +19,7 @@ from app.services.warehouse_service import (
|
||||||
get_warehouse,
|
get_warehouse,
|
||||||
update_warehouse,
|
update_warehouse,
|
||||||
delete_warehouse,
|
delete_warehouse,
|
||||||
|
query_warehouses,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -103,3 +105,33 @@ def delete_warehouse_endpoint(
|
||||||
return success_response({"deleted": ok}, request)
|
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):
|
class CheckType(str, Enum):
|
||||||
RECEIVED = "received"
|
RECEIVED = "RECEIVED"
|
||||||
TRANSFERRED = "transferred"
|
TRANSFERRED = "TRANSFERRED"
|
||||||
|
|
||||||
|
|
||||||
class Check(Base):
|
class Check(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, Any
|
from typing import Callable, Any, get_type_hints
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
@ -131,6 +131,12 @@ def require_business_access(business_id_param: str = "business_id"):
|
||||||
return result
|
return result
|
||||||
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
||||||
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
|
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 wrapper
|
||||||
return decorator
|
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]:
|
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
ctype = str(data.get('type', '')).lower()
|
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)
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
|
||||||
person_id = data.get('person_id')
|
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)
|
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
|
||||||
|
|
||||||
issue_date = _parse_iso(str(data.get('issue_date')))
|
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(
|
obj = Check(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
type=CheckType(ctype),
|
type=CheckType[ctype.upper()],
|
||||||
person_id=int(person_id) if person_id else None,
|
person_id=int(person_id) if person_id else None,
|
||||||
issue_date=issue_date,
|
issue_date=issue_date,
|
||||||
due_date=due_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:
|
if 'type' in data and data['type'] is not None:
|
||||||
ctype = str(data['type']).lower()
|
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)
|
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:
|
if 'person_id' in data:
|
||||||
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
|
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:
|
if not prop or not op:
|
||||||
continue
|
continue
|
||||||
if prop == 'type' and op == '=':
|
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 == '=':
|
elif prop == 'currency' and op == '=':
|
||||||
try:
|
try:
|
||||||
q = q.filter(Check.currency_id == int(val))
|
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 {
|
return {
|
||||||
"id": obj.id,
|
"id": obj.id,
|
||||||
"business_id": obj.business_id,
|
"business_id": obj.business_id,
|
||||||
"type": obj.type.value,
|
"type": obj.type.name.lower(),
|
||||||
"person_id": obj.person_id,
|
"person_id": obj.person_id,
|
||||||
"person_name": person_name,
|
"person_name": person_name,
|
||||||
"issue_date": obj.issue_date.isoformat(),
|
"issue_date": obj.issue_date.isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,24 @@ def _iter_product_movements(
|
||||||
"""
|
"""
|
||||||
if not product_ids:
|
if not product_ids:
|
||||||
return []
|
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(
|
q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter(
|
||||||
and_(
|
and_(
|
||||||
Document.business_id == business_id,
|
Document.business_id == business_id,
|
||||||
Document.is_proforma == False, # noqa: E712
|
Document.is_proforma == False, # noqa: E712
|
||||||
Document.document_date <= up_to_date,
|
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:
|
if exclude_document_id is not None:
|
||||||
|
|
@ -353,6 +365,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
|
||||||
total = Decimal(0)
|
total = Decimal(0)
|
||||||
for line in lines:
|
for line in lines:
|
||||||
info = line.get("extra_info") or {}
|
info = line.get("extra_info") or {}
|
||||||
|
# فقط برای کالاهای دارای کنترل موجودی
|
||||||
|
if not bool(info.get("inventory_tracked")):
|
||||||
|
continue
|
||||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||||
if info.get("cogs_amount") is not None:
|
if info.get("cogs_amount") is not None:
|
||||||
total += Decimal(str(info.get("cogs_amount")))
|
total += Decimal(str(info.get("cogs_amount")))
|
||||||
|
|
@ -486,20 +501,45 @@ def create_invoice(
|
||||||
if mv == "out":
|
if mv == "out":
|
||||||
outgoing_lines.append(ln)
|
outgoing_lines.append(ln)
|
||||||
|
|
||||||
# Ensure stock sufficiency for outgoing
|
# Resolve inventory tracking per product and annotate lines
|
||||||
if outgoing_lines:
|
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
|
||||||
_ensure_stock_sufficient(db, business_id, document_date, outgoing_lines)
|
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)
|
costing_method = _get_costing_method(data)
|
||||||
if costing_method == "fifo" and outgoing_lines:
|
if costing_method == "fifo" and tracked_outgoing_lines:
|
||||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, 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 outgoing_lines
|
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
|
||||||
i = 0
|
i = 0
|
||||||
for ln in lines_input:
|
for ln in lines_input:
|
||||||
info = ln.get("extra_info") or {}
|
info = ln.get("extra_info") or {}
|
||||||
mv = info.get("movement") or movement_hint
|
mv = info.get("movement") or movement_hint
|
||||||
if mv == "out":
|
if mv == "out" and info.get("inventory_tracked"):
|
||||||
amt = fifo_costs[i]
|
amt = fifo_costs[i]
|
||||||
i += 1
|
i += 1
|
||||||
info = dict(info)
|
info = dict(info)
|
||||||
|
|
@ -901,18 +941,41 @@ def update_invoice(
|
||||||
if mv == "out":
|
if mv == "out":
|
||||||
outgoing_lines.append(ln)
|
outgoing_lines.append(ln)
|
||||||
|
|
||||||
if outgoing_lines:
|
# Resolve and annotate inventory tracking for all lines
|
||||||
_ensure_stock_sufficient(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id)
|
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}
|
header_for_costing = data if data else {"extra_info": document.extra_info}
|
||||||
costing_method = _get_costing_method(header_for_costing)
|
costing_method = _get_costing_method(header_for_costing)
|
||||||
if costing_method == "fifo" and outgoing_lines:
|
if costing_method == "fifo" and tracked_outgoing_lines:
|
||||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id)
|
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||||
i = 0
|
i = 0
|
||||||
for ln in lines_input:
|
for ln in lines_input:
|
||||||
info = ln.get("extra_info") or {}
|
info = ln.get("extra_info") or {}
|
||||||
mv = info.get("movement") or movement_hint
|
mv = info.get("movement") or movement_hint
|
||||||
if mv == "out":
|
if mv == "out" and info.get("inventory_tracked"):
|
||||||
amt = fifo_costs[i]
|
amt = fifo_costs[i]
|
||||||
i += 1
|
i += 1
|
||||||
info = dict(info)
|
info = dict(info)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from app.core.responses import ApiError
|
||||||
from adapters.db.models.warehouse import Warehouse
|
from adapters.db.models.warehouse import Warehouse
|
||||||
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
||||||
from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest
|
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]:
|
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)
|
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/security.py
|
||||||
app/core/settings.py
|
app/core/settings.py
|
||||||
app/core/smart_normalizer.py
|
app/core/smart_normalizer.py
|
||||||
|
app/services/account_service.py
|
||||||
app/services/api_key_service.py
|
app/services/api_key_service.py
|
||||||
app/services/auth_service.py
|
app/services/auth_service.py
|
||||||
app/services/bank_account_service.py
|
app/services/bank_account_service.py
|
||||||
|
|
@ -144,6 +145,7 @@ app/services/document_service.py
|
||||||
app/services/email_service.py
|
app/services/email_service.py
|
||||||
app/services/expense_income_service.py
|
app/services/expense_income_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
|
app/services/invoice_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
app/services/petty_cash_service.py
|
app/services/petty_cash_service.py
|
||||||
app/services/price_list_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(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: AccountsPage(
|
child: AccountsPage(
|
||||||
businessId: int.parse(state.pathParameters['business_id']!),
|
businessId: int.parse(state.pathParameters['business_id']!),
|
||||||
|
authStore: _authStore!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/api_client.dart';
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
import 'package:hesabix_ui/core/auth_store.dart';
|
||||||
|
|
||||||
class AccountNode {
|
class AccountNode {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -42,8 +43,9 @@ class _VisibleNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AccountsPage extends StatefulWidget {
|
class AccountsPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
const AccountsPage({super.key, required this.businessId});
|
final AuthStore authStore;
|
||||||
|
const AccountsPage({super.key, required this.businessId, required this.authStore});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AccountsPage> createState() => _AccountsPageState();
|
State<AccountsPage> createState() => _AccountsPageState();
|
||||||
|
|
@ -156,16 +158,20 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
final pid = int.tryParse(selectedParentId!);
|
final pid = int.tryParse(selectedParentId!);
|
||||||
if (pid != null) payload["parent_id"] = pid;
|
if (pid != null) payload["parent_id"] = pid;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final api = ApiClient();
|
final api = ApiClient();
|
||||||
await api.post(
|
await api.post(
|
||||||
'/api/v1/accounts/business/${widget.businessId}/create',
|
'/api/v1/accounts/business/${widget.businessId}/create',
|
||||||
data: payload,
|
data: payload,
|
||||||
);
|
);
|
||||||
if (context.mounted) Navigator.of(ctx).pop(true);
|
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),
|
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) {
|
String _localizedAccountType(AppLocalizations t, String? value) {
|
||||||
if (value == null || value.isEmpty) return '-';
|
if (value == null || value.isEmpty) return '-';
|
||||||
final ln = t.localeName;
|
final ln = t.localeName;
|
||||||
|
|
@ -273,7 +378,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 28), // expander space
|
const SizedBox(width: 28), // expander space
|
||||||
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
|
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
|
@ -314,7 +419,21 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
),
|
),
|
||||||
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
||||||
Expanded(flex: 5, child: Text(node.name)),
|
Expanded(flex: 5, child: Text(node.name)),
|
||||||
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
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')
|
||||||
onPressed: _openCreateDialog,
|
? FloatingActionButton(
|
||||||
child: const Icon(Icons.add),
|
onPressed: _openCreateDialog,
|
||||||
),
|
child: const Icon(Icons.add),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../services/warehouse_service.dart';
|
import '../../services/warehouse_service.dart';
|
||||||
import '../../models/warehouse_model.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 {
|
class WarehousesPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -12,69 +14,81 @@ class WarehousesPage extends StatefulWidget {
|
||||||
|
|
||||||
class _WarehousesPageState extends State<WarehousesPage> {
|
class _WarehousesPageState extends State<WarehousesPage> {
|
||||||
final WarehouseService _service = WarehouseService();
|
final WarehouseService _service = WarehouseService();
|
||||||
bool _loading = true;
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
String? _error;
|
|
||||||
List<Warehouse> _items = const <Warehouse>[];
|
|
||||||
|
|
||||||
@override
|
void _refreshTable() {
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
try {
|
try {
|
||||||
setState(() {
|
final current = _tableKey.currentState as dynamic;
|
||||||
_loading = true;
|
current?.refresh();
|
||||||
_error = null;
|
} catch (_) {}
|
||||||
});
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('مدیریت انبارها')),
|
body: DataTableWidget<Warehouse>(
|
||||||
floatingActionButton: FloatingActionButton(
|
key: _tableKey,
|
||||||
onPressed: _showCreateDialog,
|
fromJson: (m) => Warehouse.fromJson(m),
|
||||||
child: const Icon(Icons.add),
|
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,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +123,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
||||||
);
|
);
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
try {
|
try {
|
||||||
final created = await _service.createWarehouse(
|
await _service.createWarehouse(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
payload: {
|
payload: {
|
||||||
'code': codeCtrl.text.trim(),
|
'code': codeCtrl.text.trim(),
|
||||||
|
|
@ -118,9 +132,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_refreshTable();
|
||||||
_items = [created, ..._items];
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
|
@ -158,7 +170,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
||||||
);
|
);
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
try {
|
try {
|
||||||
final updated = await _service.updateWarehouse(
|
await _service.updateWarehouse(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
warehouseId: w.id!,
|
warehouseId: w.id!,
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -168,9 +180,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_refreshTable();
|
||||||
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
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!);
|
final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
setState(() {
|
_refreshTable();
|
||||||
_items = _items.where((e) => e.id != w.id).toList();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue