feat(quick-sales): resolve overpayments by source #16

Merged
morrning merged 2 commits from Seyyed/Seyyed_arc:codex/quick-sales-payment-dock into master 2026-10-14 23:54:32 +03:30
11 changed files with 1918 additions and 218 deletions

View file

@ -10,8 +10,11 @@
- فشردن یک دکمه پرداخت، مبلغ داخل فیلد را به مقصد همان دکمه اضافه می‌کند.
- پس از افزودن پرداخت، فیلد مبلغ با مانده جدید به‌روزرسانی می‌شود.
- پرداخت‌های ثبت‌شده در یک فهرست مستقل و اسکرول‌دار نمایش داده می‌شوند. مبلغ هر ردیف قابل ویرایش و خود ردیف قابل حذف است.
- هر ردیف پرداخت، آیکون متناسب با نوع مقصد، تاریخ و ساعت ثبت و دکمه حذف قرمز دارد.
- کاربر می‌تواند مرز بین فضای دکمه‌ها و فهرست پرداخت‌ها را با ماوس جابه‌جا کند. عرض انتخاب‌شده برای همان کاربر و کسب‌وکار در مرورگر ذخیره می‌شود. دوبار کلیک روی مرز، عرض پیش‌فرض را برمی‌گرداند و کلیدهای جهت نیز مرز را حرکت می‌دهند.
- جمع پرداخت‌ها اگر از مبلغ فاکتور بیشتر شود، مبلغ قابل برگشت با رنگ خطا نمایش داده می‌شود. خود فیلد مبلغ در این حالت صفر می‌ماند.
- خلاصه فاکتور و دکمه‌های «ثبت» و «ثبت و چاپ» در پایین سمت چپ ثابت هستند. پنل سمت راست شامل مبلغ، دکمه‌ها و فهرست پرداخت‌ها با فلش جمع یا باز می‌شود تا در حالت جمع‌شده فضای بیشتری به فهرست کالاها برسد. وضعیت انتخاب‌شده برای همان کاربر و کسب‌وکار در مرورگر ذخیره می‌شود.
- «جزئیات سند» و بخش «شرح سند» در ستون سمت چپ به‌صورت پیش‌فرض باز هستند.
- جمع پرداخت‌ها اگر از مبلغ فاکتور بیشتر شود، مبلغ اضافه‌دریافت با رنگ خطا نمایش داده می‌شود. با انتخاب آن، پنجره «تعیین تکلیف اضافه‌دریافت» باز می‌شود. خود فیلد مبلغ در این حالت صفر می‌ماند.
- ثبت فاکتور بدون هیچ پرداختی، پس از تأیید کاربر، کل مبلغ را بدهی مشتری ثبت می‌کند.
## دکمه‌های پرداخت
@ -50,18 +53,34 @@
همه مقصدها در بک‌اند از نظر وجود، فعال بودن و تعلق به کسب‌وکار کنترل می‌شوند. شخص مقصد نیز نمی‌تواند همان مشتری فاکتور باشد.
## ثبت حسابداری پرداخت اضافه
## تعیین تکلیف و ثبت حسابداری اضافه‌دریافت
اگر جمع دریافت‌ها از خالص فاکتور بیشتر باشد، ثبت فقط پس از تأیید صریح برگشت وجه انجام می‌شود. صندوق نقدی انتخاب‌شده باید فعال، متعلق به همان کسب‌وکار و هم‌ارز فاکتور باشد.
اگر جمع دریافت‌ها از خالص فاکتور بیشتر باشد، تمام مبلغ اضافه باید پیش از ثبت تعیین تکلیف شود. کاربر می‌تواند مبلغ را بین چند ردیف از منابع زیر تقسیم کند:
1. **صندوق:** صندوق فعال و هم‌ارز فاکتور انتخاب می‌شود.
2. **بانک:** حساب بانکی فعال و هم‌ارز فاکتور همراه مبلغ کارمزد اختیاری انتخاب می‌شود.
3. **شخص:** هر شخص متعلق به کسب‌وکار به‌جز خود مشتری فاکتور قابل انتخاب است. حساب دریافتنی `10401` او به مبلغ برگشتی بستانکار می‌شود؛ بنابراین ممکن است بدهی او کاهش یابد یا شخص از کسب‌وکار طلبکار شود.
4. **بستانکاری مشتری:** تمام یا بخشی از مبلغ به مشتری برگردانده نمی‌شود و به‌عنوان بستانکاری او باقی می‌ماند. زدن دکمه ثبت با وجود اضافه‌دریافت همیشه پنجره تعیین تکلیف را باز می‌کند. کاربر می‌تواند برای مشتری مشخص «ادامه بدون تعیین تکلیف» را بزند تا تمام اضافه‌دریافت به بستانکاری همان مشتری منتقل شود. این گزینه برای مشتری ناشناس نمایش داده نمی‌شود.
جمع مبالغ برگشتی و بستانکاری باید دقیقاً برابر اضافه‌دریافت باشد. کارمزد بانک جزو مبلغ دریافتی مشتری نیست و جداگانه محاسبه می‌شود.
در یک تراکنش دیتابیس این عملیات انجام می‌شود:
1. فاکتور فروش ثبت می‌شود.
2. برای هر ردیف پرداخت، سند دریافت مستقل ساخته می‌شود.
3. برای مبلغ اضافه، یک سند پرداخت از صندوق به مشتری ساخته می‌شود.
3. برای مجموع مبلغی که واقعاً برگردانده شده، یک سند پرداخت با تمام منابع انتخاب‌شده ساخته می‌شود.
4. شناسه سند برگشت وجه جدا از اسناد دریافت در `extra_info.links.quick_sale_change_payment_document_id` ذخیره می‌شود.
مانده فاکتور از دریافت ناخالص منهای وجه برگشتی محاسبه می‌شود. در نتیجه فاکتوری که وجه اضافه آن به مشتری برگردانده شده، تسویه‌شده باقی می‌ماند. هنگام ویرایش، تبدیل به پیش‌فاکتور یا حذف فاکتور، سند برگشت وجه نیز در همان تراکنش به‌روزرسانی یا حذف می‌شود.
ثبت‌های منبع وجه چنین هستند:
- صندوق: حساب صندوق به مبلغ برگشتی بستانکار می‌شود.
- بانک: حساب بانک به اندازه «مبلغ برگشتی + کارمزد» بستانکار و حساب `70902` با عنوان «کارمزد خدمات بانکی» به مبلغ کارمزد بدهکار می‌شود.
- شخص: حساب دریافتنی اشخاص `10401` برای شخص پرداخت‌کننده بستانکار می‌شود. اگر مانده قبلی او بدهکار باشد، بدهی کاهش می‌یابد؛ اگر مبلغ برگشت بیشتر از بدهی باشد، شخص به همان میزان از کسب‌وکار طلبکار می‌شود.
- مشتری: حساب دریافتنی مشتری فقط به اندازه مبلغی که واقعاً پس داده شده بدهکار می‌شود؛ بخش پس‌داده‌نشده به‌صورت بستانکاری مشتری باقی می‌ماند.
مانده فاکتور از دریافت ناخالص منهای مبلغی که واقعاً به مشتری برگردانده شده محاسبه می‌شود؛ کارمزد بانکی در این محاسبه دخالت ندارد. هنگام ویرایش، تبدیل به پیش‌فاکتور یا حذف فاکتور، سند برگشت وجه نیز در همان تراکنش به‌روزرسانی یا حذف می‌شود.
تعیین تکلیف همراه هر فروش پارک‌شده در مرورگر ذخیره می‌شود. تغییر مبلغ پرداخت، مشتری یا مبلغ اضافه‌دریافت، تعیین تکلیف قبلی را نامعتبر می‌کند و هنگام ثبت، پنجره دوباره نمایش داده می‌شود. بستانکاری کامل مشتری نیز فقط پس از انتخاب صریح «ادامه بدون تعیین تکلیف» معتبر است.
## نکات سازگاری
@ -74,12 +93,15 @@
1. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون نقدی و ۶ میلیون بانکی ثبت می‌شود و مانده به صفر می‌رسد.
2. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون بانکی ثبت می‌شود و فیلد مبلغ ۶ میلیون را نشان می‌دهد.
3. مبلغ فاکتور ۱۰ میلیون است؛ ۱۱ میلیون دریافت می‌شود، مبلغ برگشتی ۱ میلیون قرمز نمایش داده می‌شود و پس از تأیید، سند پرداخت ۱ میلیون از صندوق ساخته می‌شود.
4. دو پرداخت به دو حساب بانکی متفاوت به‌صورت دو ردیف مستقل باقی می‌مانند.
5. پرداخت به شخص، آن شخص را بدهکار و مشتری فاکتور را به همان مبلغ بستانکار می‌کند.
6. حذف یا ویرایش فاکتور دارای وجه برگشتی، اسناد دریافت و برگشت مرتبط را نیز هماهنگ می‌کند.
7. کاربر عرض بخش دکمه‌ها را تغییر می‌دهد و پس از بازکردن دوباره صفحه همان عرض بازیابی می‌شود.
8. میانبر تعریف‌شده، مبلغ جاری فیلد را به مقصد همان دکمه اضافه می‌کند.
3. مبلغ فاکتور ۱۰ میلیون است؛ ۱۱ میلیون دریافت می‌شود و ۶۰۰ هزار از صندوق و ۴۰۰ هزار از بانک به مشتری برگردانده می‌شود.
4. از یک میلیون اضافه‌دریافت، ۷۰۰ هزار به مشتری برگردانده و ۳۰۰ هزار به بستانکاری همان مشتری منتقل می‌شود.
5. یک میلیون از بانک به مشتری برگردانده و ۲۰ هزار کارمزد ثبت می‌شود؛ بانک ۱٬۰۲۰٬۰۰۰ بستانکار، حساب `70902` بیست هزار بدهکار و مانده فاکتور فقط یک میلیون تعدیل می‌شود.
6. برگشت از شخص حتی بیشتر از بدهی قبلی او مجاز است و مازاد، شخص را در حساب `10401` بستانکار می‌کند.
7. دو پرداخت به دو حساب بانکی متفاوت به‌صورت دو ردیف مستقل باقی می‌مانند.
8. پرداخت به شخص، آن شخص را بدهکار و مشتری فاکتور را به همان مبلغ بستانکار می‌کند.
9. حذف یا ویرایش فاکتور دارای وجه برگشتی، اسناد دریافت و برگشت مرتبط را نیز هماهنگ می‌کند.
10. کاربر عرض بخش دکمه‌ها را تغییر می‌دهد و پس از بازکردن دوباره صفحه همان عرض بازیابی می‌شود.
11. میانبر تعریف‌شده، مبلغ جاری فیلد را به مقصد همان دکمه اضافه می‌کند.
## بررسی‌های خودکار

View file

@ -2775,6 +2775,18 @@ def _quick_sale_change_amount(document: Document, payments: List[Dict[str, Any]]
return max(Decimal(0), paid - total)
def _quick_sale_change_document_amount(document: Document) -> Decimal:
"""مبلغی که واقعاً از سند برگشت فروش سریع به مشتری پرداخت شده است."""
extra = document.extra_info if isinstance(document.extra_info, dict) else {}
raw = extra.get("returned_amount", extra.get("change_amount"))
if raw is not None:
try:
return max(Decimal(0), Decimal(str(raw)))
except Exception:
pass
return _sum_receipt_doc_account_payments(document)
def _create_quick_sale_change_payment(
db: Session,
*,
@ -2785,15 +2797,24 @@ def _create_quick_sale_change_payment(
payments: List[Dict[str, Any]],
change_request: Dict[str, Any] | None,
) -> int | None:
"""ثبت وجه برگشتی فروش سریع از صندوق، در همان تراکنش ساخت فاکتور."""
"""ثبت تعیین تکلیف اضافه‌دریافت فروش سریع در همان تراکنش فاکتور."""
change_amount = _quick_sale_change_amount(document, payments)
if change_amount <= Decimal("0.01"):
return None
request = dict(change_request) if isinstance(change_request, dict) else {}
if request.get("confirmed_returned") is not True:
# ثبت بدون تعیین منبع برگشت برای مشتری مشخص، کل اضافه‌دریافت را
# بستانکاری مشتری نگه می‌دارد. مشتری ناشناس پایین‌تر رد می‌شود.
if not request:
request = {
"confirmed": True,
"amount": change_amount,
"credit_amount": change_amount,
"allocations": [],
}
if request.get("confirmed") is not True and request.get("confirmed_returned") is not True:
raise ApiError(
"QUICK_SALE_CHANGE_CONFIRMATION_REQUIRED",
"باید پرداخت مبلغ قابل برگشت به مشتری تأیید شود",
"باید تعیین تکلیف مبلغ اضافه‌دریافت تأیید شود",
http_status=400,
)
requested_amount = Decimal(str(request.get("amount", 0) or 0))
@ -2803,35 +2824,235 @@ def _create_quick_sale_change_payment(
"مبلغ قابل برگشت با اضافه‌پرداخت فاکتور هم‌خوانی ندارد",
http_status=400,
)
try:
cash_register_id = int(request.get("cash_register_id"))
except (TypeError, ValueError) as exc:
allocations = request.get("allocations")
# سازگاری با کلاینت قبلی: تمام مبلغ از یک صندوق برگردانده می‌شد.
if not isinstance(allocations, list):
allocations = []
if request.get("cash_register_id") is not None:
allocations.append({
"source_type": "cash_register",
"source_id": request.get("cash_register_id"),
"amount": requested_amount,
})
if len(allocations) > 50:
raise ApiError(
"QUICK_SALE_CHANGE_CASH_REGISTER_REQUIRED",
"برای برگشت وجه انتخاب صندوق نقدی الزامی است",
"QUICK_SALE_CHANGE_TOO_MANY_ALLOCATIONS",
"حداکثر ۵۰ ردیف برای برگشت وجه مجاز است",
http_status=400,
)
try:
credited_amount = Decimal(str(request.get("credit_amount", 0) or 0))
except Exception as exc:
raise ApiError(
"QUICK_SALE_CHANGE_CREDIT_INVALID",
"مبلغ بستانکاری مشتری نامعتبر است",
http_status=400,
) from exc
register = (
db.query(CashRegister)
.filter(
CashRegister.id == cash_register_id,
CashRegister.business_id == int(business_id),
CashRegister.is_active == True, # noqa: E712
)
.first()
)
if not register:
if credited_amount < 0:
raise ApiError(
"QUICK_SALE_CHANGE_CASH_REGISTER_NOT_FOUND",
"صندوق برگشت وجه یافت نشد یا غیرفعال است",
http_status=404,
)
if int(register.currency_id) != int(document.currency_id):
raise ApiError(
"QUICK_SALE_CHANGE_CURRENCY_MISMATCH",
"ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست",
"QUICK_SALE_CHANGE_CREDIT_INVALID",
"مبلغ بستانکاری مشتری نمی‌تواند منفی باشد",
http_status=400,
)
normalized_allocations: List[Dict[str, Any]] = []
returned_amount = Decimal(0)
total_bank_commission = Decimal(0)
for index, raw in enumerate(allocations):
if not isinstance(raw, dict):
raise ApiError(
"QUICK_SALE_CHANGE_ALLOCATION_INVALID",
f"ردیف برگشت {index + 1} نامعتبر است",
http_status=400,
)
source_type = str(raw.get("source_type") or "").strip().lower()
if source_type not in {"cash_register", "bank", "person"}:
raise ApiError(
"QUICK_SALE_CHANGE_SOURCE_INVALID",
"منبع برگشت وجه باید صندوق، بانک یا شخص باشد",
http_status=400,
)
try:
source_id = int(raw.get("source_id"))
amount = Decimal(str(raw.get("amount", 0) or 0))
commission = Decimal(str(raw.get("commission", 0) or 0))
except Exception as exc:
raise ApiError(
"QUICK_SALE_CHANGE_ALLOCATION_INVALID",
f"مبلغ یا منبع ردیف برگشت {index + 1} نامعتبر است",
http_status=400,
) from exc
if source_id <= 0 or amount <= 0 or commission < 0:
raise ApiError(
"QUICK_SALE_CHANGE_ALLOCATION_INVALID",
f"اطلاعات ردیف برگشت {index + 1} کامل نیست",
http_status=400,
)
if source_type != "bank" and commission > 0:
raise ApiError(
"QUICK_SALE_CHANGE_COMMISSION_NOT_ALLOWED",
"کارمزد فقط برای برگشت وجه بانکی مجاز است",
http_status=400,
)
normalized_allocations.append({
"source_type": source_type,
"source_id": source_id,
"amount": amount,
"commission": commission,
})
returned_amount += amount
total_bank_commission += commission
if abs((returned_amount + credited_amount) - change_amount) > Decimal("0.01"):
raise ApiError(
"QUICK_SALE_CHANGE_RESOLUTION_MISMATCH",
"جمع وجه برگشتی و بستانکاری مشتری با اضافه‌دریافت فاکتور هم‌خوانی ندارد",
http_status=400,
)
if credited_amount > Decimal("0.01"):
from adapters.db.models.quick_sales_settings import QuickSalesSetting
settings = (
db.query(QuickSalesSetting)
.filter(QuickSalesSetting.business_id == int(business_id))
.first()
)
anonymous_id = getattr(settings, "default_anonymous_customer_id", None)
if anonymous_id is not None and int(anonymous_id) == int(person_id):
raise ApiError(
"QUICK_SALE_CHANGE_ANONYMOUS_CREDIT_NOT_ALLOWED",
"بستانکار کردن مشتری ناشناس مجاز نیست؛ وجه باید کامل برگردانده شود",
http_status=400,
)
account_lines: List[Dict[str, Any]] = []
resolution_rows: List[Dict[str, Any]] = []
receivable_account: Account | None = None
for allocation in normalized_allocations:
source_type = allocation["source_type"]
source_id = int(allocation["source_id"])
amount = Decimal(allocation["amount"])
commission = Decimal(allocation["commission"])
if source_type == "cash_register":
register = (
db.query(CashRegister)
.filter(
CashRegister.id == source_id,
CashRegister.business_id == int(business_id),
CashRegister.is_active == True, # noqa: E712
)
.first()
)
if not register:
raise ApiError(
"QUICK_SALE_CHANGE_CASH_REGISTER_NOT_FOUND",
"صندوق برگشت وجه یافت نشد یا غیرفعال است",
http_status=404,
)
if int(register.currency_id) != int(document.currency_id):
raise ApiError(
"QUICK_SALE_CHANGE_CURRENCY_MISMATCH",
"ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست",
http_status=400,
)
account_lines.append({
"transaction_type": "cash_register",
"cash_register_id": source_id,
"cash_register_name": register.name,
"amount": float(amount),
"transaction_date": document.document_date.isoformat(),
"description": f"برگشت اضافه‌دریافت فاکتور {document.code}",
})
source_name = register.name
elif source_type == "bank":
bank = (
db.query(BankAccount)
.filter(
BankAccount.id == source_id,
BankAccount.business_id == int(business_id),
BankAccount.is_active == True, # noqa: E712
)
.first()
)
if not bank:
raise ApiError(
"QUICK_SALE_CHANGE_BANK_NOT_FOUND",
"حساب بانکی برگشت وجه یافت نشد یا غیرفعال است",
http_status=404,
)
if int(bank.currency_id) != int(document.currency_id):
raise ApiError(
"QUICK_SALE_CHANGE_CURRENCY_MISMATCH",
"ارز حساب بانکی برگشت وجه با ارز فاکتور یکسان نیست",
http_status=400,
)
account_lines.append({
"transaction_type": "bank",
"bank_id": source_id,
"bank_name": bank.name,
"amount": float(amount + commission),
"transaction_date": document.document_date.isoformat(),
"description": f"برگشت اضافه‌دریافت فاکتور {document.code}",
})
source_name = bank.name
else:
source_person = (
db.query(Person)
.filter(
Person.id == source_id,
Person.business_id == int(business_id),
)
.first()
)
if not source_person:
raise ApiError(
"QUICK_SALE_CHANGE_PERSON_NOT_FOUND",
"شخص پرداخت‌کننده برگشت وجه یافت نشد یا غیرفعال است",
http_status=404,
)
if source_id == int(person_id):
raise ApiError(
"QUICK_SALE_CHANGE_SOURCE_IS_CUSTOMER",
"مشتری فاکتور نمی‌تواند منبع برگشت وجه همان فاکتور باشد",
http_status=400,
)
if receivable_account is None:
receivable_account = (
db.query(Account)
.filter(Account.business_id.is_(None), Account.code == "10401")
.first()
)
if not receivable_account:
raise ApiError(
"ACCOUNT_NOT_FOUND",
"حساب دریافتنی اشخاص (10401) یافت نشد",
http_status=500,
)
account_lines.append({
"transaction_type": "account",
"account_id": int(receivable_account.id),
"person_id": source_id,
"detail_type": "person",
"detail_name": source_person.alias_name,
"amount": float(amount),
"transaction_date": document.document_date.isoformat(),
"description": f"برگشت وجه فاکتور {document.code} توسط شخص",
})
source_name = source_person.alias_name
resolution_rows.append({
"source_type": source_type,
"source_id": source_id,
"source_name": source_name,
"amount": float(amount),
"commission": float(commission),
})
if returned_amount <= Decimal("0.01"):
return None
from app.services.receipt_payment_service import create_receipt_payment
rp = create_receipt_payment(
@ -2846,17 +3067,17 @@ def _create_quick_sale_change_payment(
"description": f"برگشت اضافه‌دریافت فروش سریع فاکتور {document.code}",
"person_lines": [{
"person_id": int(person_id),
"amount": float(change_amount),
"amount": float(returned_amount),
"description": f"وجه برگشتی فاکتور {document.code}",
"extra_info": {"invoice_id": int(document.id)},
}],
"account_lines": [{
"transaction_type": "cash_register",
"cash_register_id": cash_register_id,
"cash_register_name": register.name,
"amount": float(change_amount),
"transaction_date": document.document_date.isoformat(),
"description": f"برگشت اضافه‌دریافت فاکتور {document.code}",
}],
"account_lines": account_lines,
"fx_adjustment_lines": ([{
"account_code": "70902",
"side": "debit",
"amount": float(total_bank_commission),
"description": "کارمزد خدمات بانکی برگشت وجه فروش سریع",
}] if total_bank_commission > 0 else []),
"extra_info": {
"source": "quick_sale_change",
"invoice_id": int(document.id),
@ -2872,7 +3093,12 @@ def _create_quick_sale_change_payment(
)
)
),
"change_amount": float(change_amount),
"overpayment_amount": float(change_amount),
"change_amount": float(returned_amount),
"returned_amount": float(returned_amount),
"credited_amount": float(credited_amount),
"bank_commission": float(total_bank_commission),
"allocations": resolution_rows,
},
},
)
@ -10656,7 +10882,7 @@ def batch_calculate_invoices_remaining(
continue
processed_doc_ids.add(doc_id_int)
if (rp_doc.extra_info or {}).get("source") == "quick_sale_change":
total_paid -= _sum_receipt_doc_account_payments(rp_doc)
total_paid -= _quick_sale_change_document_amount(rp_doc)
else:
total_paid += _sum_receipt_doc_payments_for_invoice(
rp_doc, inv_id, via_person_line=True
@ -10671,7 +10897,7 @@ def batch_calculate_invoices_remaining(
change_doc = rp_docs_by_id.get(parsed_change_doc_id)
if change_doc:
processed_doc_ids.add(parsed_change_doc_id)
total_paid -= _sum_receipt_doc_account_payments(change_doc)
total_paid -= _quick_sale_change_document_amount(change_doc)
remaining = total_amount - total_paid
results[inv_id] = {
@ -10801,7 +11027,7 @@ def calculate_invoice_remaining(
continue
processed_doc_ids.add(doc.id)
if (doc.extra_info or {}).get("source") == "quick_sale_change":
total_paid -= _sum_receipt_doc_account_payments(doc)
total_paid -= _quick_sale_change_document_amount(doc)
else:
total_paid += _sum_receipt_doc_payments_for_invoice(
doc, invoice_id, via_person_line=True
@ -10825,7 +11051,7 @@ def calculate_invoice_remaining(
)
if change_doc:
processed_doc_ids.add(parsed_change_doc_id)
total_paid -= _sum_receipt_doc_account_payments(change_doc)
total_paid -= _quick_sale_change_document_amount(change_doc)
remaining = total_amount - total_paid

View file

@ -2,13 +2,58 @@ from __future__ import annotations
from types import SimpleNamespace
from app.services.invoice_service import _quick_sale_change_amount
import pytest
from app.core.responses import ApiError
from app.services import receipt_payment_service
from app.services.invoice_service import (
_create_quick_sale_change_payment,
_quick_sale_change_amount,
_quick_sale_change_document_amount,
)
def _invoice(total: int):
return SimpleNamespace(extra_info={"totals": {"net": total}})
class _FakeQuery:
def __init__(self, result):
self.result = result
def filter(self, *args, **kwargs):
return self
def join(self, *args, **kwargs):
return self
def first(self):
return self.result
def one(self):
return self.result
class _FakeDb:
def __init__(self, query_results):
self.query_results = list(query_results)
def query(self, *args, **kwargs):
assert self.query_results, "unexpected database query"
return _FakeQuery(self.query_results.pop(0))
def _saved_invoice(total: int = 1_000_000):
return SimpleNamespace(
id=55,
code="QS-55",
currency_id=1,
fiscal_year_id=9,
document_date=SimpleNamespace(isoformat=lambda: "2026-10-14"),
extra_info={"totals": {"net": total}},
)
def test_quick_sale_change_is_zero_when_payments_do_not_exceed_total() -> None:
assert _quick_sale_change_amount(
_invoice(1_000_000),
@ -28,3 +73,227 @@ def test_quick_sale_change_uses_settlement_amount_for_foreign_payment() -> None:
_invoice(1_000_000),
[{"amount": 10, "settles_amount": 1_100_000}],
) == 100_000
def test_quick_sale_change_document_uses_returned_amount_not_bank_outflow() -> None:
document = SimpleNamespace(
extra_info={
"source": "quick_sale_change",
"returned_amount": 500_000,
"bank_commission": 20_000,
},
lines=[],
)
assert _quick_sale_change_document_amount(document) == 500_000
def test_quick_sale_change_document_supports_legacy_change_amount() -> None:
document = SimpleNamespace(
extra_info={"source": "quick_sale_change", "change_amount": 125_000},
lines=[],
)
assert _quick_sale_change_document_amount(document) == 125_000
def test_quick_sale_change_bank_fee_is_separate_from_customer_refund(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = {}
def fake_create_receipt_payment(**kwargs):
captured.update(kwargs)
return {"id": 91}
monkeypatch.setattr(
receipt_payment_service,
"create_receipt_payment",
fake_create_receipt_payment,
)
bank = SimpleNamespace(id=7, name="بانک سامان", currency_id=1, is_active=True)
db = _FakeDb([bank])
result = _create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request={
"amount": 500_000,
"confirmed": True,
"credit_amount": 0,
"allocations": [
{
"source_type": "bank",
"source_id": 7,
"amount": 500_000,
"commission": 20_000,
}
],
},
)
assert result == 91
assert captured["commit"] is False
payload = captured["data"]
assert payload["person_lines"][0]["amount"] == 500_000
assert payload["account_lines"][0]["amount"] == 520_000
assert payload["fx_adjustment_lines"] == [
{
"account_code": "70902",
"side": "debit",
"amount": 20_000,
"description": "کارمزد خدمات بانکی برگشت وجه فروش سریع",
}
]
assert payload["extra_info"]["returned_amount"] == 500_000
assert payload["extra_info"]["bank_commission"] == 20_000
def test_quick_sale_change_can_leave_full_amount_as_customer_credit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
receipt_payment_service,
"create_receipt_payment",
lambda **kwargs: pytest.fail("credit-only resolution must not create payment"),
)
settings = SimpleNamespace(default_anonymous_customer_id=99)
db = _FakeDb([settings])
result = _create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request={
"amount": 500_000,
"confirmed": True,
"credit_amount": 500_000,
"allocations": [],
},
)
assert result is None
def test_quick_sale_change_defaults_to_customer_credit_without_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
receipt_payment_service,
"create_receipt_payment",
lambda **kwargs: pytest.fail("default credit must not create payment"),
)
settings = SimpleNamespace(default_anonymous_customer_id=99)
db = _FakeDb([settings])
result = _create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request=None,
)
assert result is None
def test_quick_sale_change_without_resolution_still_rejects_anonymous_customer() -> None:
settings = SimpleNamespace(default_anonymous_customer_id=10)
db = _FakeDb([settings])
with pytest.raises(ApiError) as exc_info:
_create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request=None,
)
assert exc_info.value.code == "QUICK_SALE_CHANGE_ANONYMOUS_CREDIT_NOT_ALLOWED"
def test_quick_sale_change_rejects_credit_for_anonymous_customer() -> None:
settings = SimpleNamespace(default_anonymous_customer_id=10)
db = _FakeDb([settings])
with pytest.raises(ApiError) as exc_info:
_create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request={
"amount": 500_000,
"confirmed": True,
"credit_amount": 500_000,
"allocations": [],
},
)
assert exc_info.value.code == "QUICK_SALE_CHANGE_ANONYMOUS_CREDIT_NOT_ALLOWED"
def test_quick_sale_change_from_person_credits_their_receivable_account(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = {}
def fake_create_receipt_payment(**kwargs):
captured.update(kwargs)
return {"id": 92}
monkeypatch.setattr(
receipt_payment_service,
"create_receipt_payment",
fake_create_receipt_payment,
)
source_person = SimpleNamespace(
id=15,
alias_name="صندوقدار",
business_id=2,
is_active=True,
)
receivable_account = SimpleNamespace(id=10401, code="10401")
db = _FakeDb([source_person, receivable_account])
result = _create_quick_sale_change_payment(
db,
business_id=2,
user_id=4,
document=_saved_invoice(),
person_id=10,
payments=[{"amount": 1_500_000}],
change_request={
"amount": 500_000,
"confirmed": True,
"credit_amount": 0,
"allocations": [
{
"source_type": "person",
"source_id": 15,
"amount": 500_000,
}
],
},
)
assert result == 92
source_line = captured["data"]["account_lines"][0]
assert source_line["transaction_type"] == "account"
assert source_line["account_id"] == 10401
assert source_line["person_id"] == 15
assert source_line["amount"] == 500_000

View file

@ -0,0 +1,128 @@
enum QuickSalesChangeSourceType { cashRegister, bank, person }
extension QuickSalesChangeSourceTypeValue on QuickSalesChangeSourceType {
String get value => switch (this) {
QuickSalesChangeSourceType.cashRegister => 'cash_register',
QuickSalesChangeSourceType.bank => 'bank',
QuickSalesChangeSourceType.person => 'person',
};
String get label => switch (this) {
QuickSalesChangeSourceType.cashRegister => 'صندوق',
QuickSalesChangeSourceType.bank => 'بانک',
QuickSalesChangeSourceType.person => 'شخص',
};
static QuickSalesChangeSourceType? tryParse(Object? value) =>
switch (value?.toString()) {
'cash_register' => QuickSalesChangeSourceType.cashRegister,
'bank' => QuickSalesChangeSourceType.bank,
'person' => QuickSalesChangeSourceType.person,
_ => null,
};
}
class QuickSalesChangeAllocation {
final String id;
final QuickSalesChangeSourceType sourceType;
final int sourceId;
final String sourceName;
final num amount;
final num commission;
const QuickSalesChangeAllocation({
required this.id,
required this.sourceType,
required this.sourceId,
required this.sourceName,
required this.amount,
this.commission = 0,
});
Map<String, dynamic> toJson() => {
'id': id,
'source_type': sourceType.value,
'source_id': sourceId,
'source_name': sourceName,
'amount': amount,
if (sourceType == QuickSalesChangeSourceType.bank) 'commission': commission,
};
factory QuickSalesChangeAllocation.fromJson(Map<String, dynamic> json) {
final type = QuickSalesChangeSourceTypeValue.tryParse(json['source_type']);
if (type == null) {
throw const FormatException('Invalid quick sale change source type');
}
return QuickSalesChangeAllocation(
id: json['id']?.toString() ?? '',
sourceType: type,
sourceId: (json['source_id'] as num?)?.toInt() ?? 0,
sourceName: json['source_name']?.toString() ?? '',
amount: (json['amount'] as num?) ?? 0,
commission: (json['commission'] as num?) ?? 0,
);
}
}
class QuickSalesChangeResolution {
final num overpaymentAmount;
final List<QuickSalesChangeAllocation> allocations;
final num creditAmount;
const QuickSalesChangeResolution({
required this.overpaymentAmount,
required this.allocations,
required this.creditAmount,
});
num get returnedAmount =>
allocations.fold<num>(0, (sum, allocation) => sum + allocation.amount);
num get bankCommission => allocations.fold<num>(
0,
(sum, allocation) => sum + allocation.commission,
);
num get resolvedAmount => returnedAmount + creditAmount;
bool isValidFor(num amount, num epsilon) {
if ((overpaymentAmount - amount).abs() > epsilon) return false;
if ((resolvedAmount - amount).abs() > epsilon) return false;
if (creditAmount < 0) return false;
return allocations.every(
(row) =>
row.sourceId > 0 &&
row.amount > 0 &&
row.commission >= 0 &&
(row.sourceType == QuickSalesChangeSourceType.bank ||
row.commission == 0),
);
}
Map<String, dynamic> toJson() => {
'amount': overpaymentAmount,
'allocations': allocations.map((row) => row.toJson()).toList(),
'credit_amount': creditAmount,
'confirmed': true,
};
factory QuickSalesChangeResolution.fromJson(Map<String, dynamic> json) {
final allocations = <QuickSalesChangeAllocation>[];
final rawRows = json['allocations'];
if (rawRows is List) {
for (final raw in rawRows) {
if (raw is! Map) continue;
try {
allocations.add(
QuickSalesChangeAllocation.fromJson(Map<String, dynamic>.from(raw)),
);
} catch (_) {}
}
}
return QuickSalesChangeResolution(
overpaymentAmount: (json['amount'] as num?) ?? 0,
allocations: allocations,
creditAmount: (json['credit_amount'] as num?) ?? 0,
);
}
}

View file

@ -7,6 +7,7 @@ import '../core/date_utils.dart';
import 'customer_model.dart';
import 'invoice_line_item.dart';
import 'invoice_transaction.dart';
import 'quick_sales_change_resolution.dart';
/// یک فروش باز در صندوق فروش سریع (Park / Hold).
///
@ -34,6 +35,7 @@ class QuickSalesParkedSale {
final int shareExpiryHours;
final int itemCount;
final num totalAmount;
final QuickSalesChangeResolution? changeResolution;
const QuickSalesParkedSale({
required this.id,
@ -58,6 +60,7 @@ class QuickSalesParkedSale {
this.shareExpiryHours = 168,
this.itemCount = 0,
this.totalAmount = 0,
this.changeResolution,
});
static String newId() => const Uuid().v4();
@ -104,6 +107,8 @@ class QuickSalesParkedSale {
int? shareExpiryHours,
int? itemCount,
num? totalAmount,
QuickSalesChangeResolution? changeResolution,
bool clearChangeResolution = false,
}) {
return QuickSalesParkedSale(
id: id,
@ -130,6 +135,9 @@ class QuickSalesParkedSale {
shareExpiryHours: shareExpiryHours ?? this.shareExpiryHours,
itemCount: itemCount ?? this.itemCount,
totalAmount: totalAmount ?? this.totalAmount,
changeResolution: clearChangeResolution
? null
: changeResolution ?? this.changeResolution,
);
}
@ -157,6 +165,8 @@ class QuickSalesParkedSale {
'share_expiry_hours': shareExpiryHours,
'item_count': itemCount,
'total_amount': totalAmount,
if (changeResolution != null)
'change_resolution': changeResolution!.toJson(),
};
}
@ -209,7 +219,8 @@ class QuickSalesParkedSale {
json['cash_follows_total'] != false && (payments.length <= 1),
cashRegisterId: json['cash_register_id']?.toString(),
warehouseId: _asInt(json['warehouse_id']),
documentDate: HesabixDateUtils.parseApiDate(
documentDate:
HesabixDateUtils.parseApiDate(
json['document_date'],
rawValue: json['document_date_raw'],
) ??
@ -226,6 +237,11 @@ class QuickSalesParkedSale {
shareExpiryHours: _asInt(json['share_expiry_hours']) ?? 168,
itemCount: _asInt(json['item_count']) ?? items.length,
totalAmount: _asNum(json['total_amount']) ?? 0,
changeResolution: json['change_resolution'] is Map
? QuickSalesChangeResolution.fromJson(
Map<String, dynamic>.from(json['change_resolution'] as Map),
)
: null,
);
}

View file

@ -30,10 +30,12 @@ import '../../services/currency_service.dart';
import '../../models/invoice_line_item.dart';
import '../../models/invoice_transaction.dart';
import '../../models/quick_sales_parked_sale.dart';
import '../../models/quick_sales_change_resolution.dart';
import '../../widgets/invoice/customer_combobox_widget.dart';
import '../../widgets/quick_sales/quick_sales_parked_sales_bar.dart';
import '../../widgets/quick_sales/quick_sales_payment_composer.dart';
import '../../widgets/quick_sales/quick_sales_payment_dock.dart';
import '../../widgets/quick_sales/quick_sales_change_resolution_dialog.dart';
import '../../widgets/invoice/warehouse_combobox_widget.dart';
import '../../utils/currency_display_utils.dart';
import '../../widgets/permission/access_denied_page.dart';
@ -157,6 +159,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
// پرداخت
List<InvoiceTransaction> _payments = [];
QuickSalesChangeResolution? _changeResolution;
String? _selectedCashRegisterId;
bool _autoCreatePaymentDocument = true;
bool _cashFollowsTotal = false;
@ -658,6 +661,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
shareExpiryHours: _shareExpiryHours,
itemCount: _cartItems.length,
totalAmount: _totalAmount,
changeResolution: _validChangeResolution,
);
}
@ -672,6 +676,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_selectedCustomer = customer;
_cartItems = sale.cartItems.map(QuickSalesParkedSale.cloneLine).toList();
_payments = List<InvoiceTransaction>.from(sale.payments);
_changeResolution = sale.changeResolution;
_cashFollowsTotal = sale.cashFollowsTotal;
_shareRemainingEnabled = false;
_checkoutDiscountExpanded = false;
@ -699,6 +704,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_selectedCustomer = _anonymousCustomer;
_cartItems = [];
_payments = [];
_changeResolution = null;
_cashFollowsTotal = false;
_shareRemainingEnabled = false;
_checkoutDiscountExpanded = false;
@ -1043,6 +1049,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_autoCreatePaymentDocument = value;
_shareRemainingEnabled = false;
_payments = [];
_changeResolution = null;
_cashFollowsTotal = false;
if (!value) {
_syncShareChannelDefaults();
@ -1075,6 +1082,34 @@ class _QuickSalesPageState extends State<QuickSalesPage>
num get _changeAmount => _isOverpaid ? _paidAmount - _totalAmount : 0;
QuickSalesChangeResolution? get _validChangeResolution {
final value = _changeResolution;
if (!_isOverpaid || value == null) return null;
return value.isValidFor(_changeAmount, _paymentEpsilon) ? value : null;
}
Future<QuickSalesChangeResolution?> _openChangeResolutionDialog() async {
if (!_isOverpaid) return null;
final result = await showDialog<QuickSalesChangeResolution>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => QuickSalesChangeResolutionDialog(
businessId: widget.businessId,
currencyId: _defaultCurrencyId,
overpaymentAmount: _changeAmount,
currencyUnit: _paymentCurrencyUnit,
decimalPlaces: _invoiceCurrencyDecimalPlaces,
customerName: _selectedCustomer?.name ?? 'مشتری ناشناس',
customerId: _selectedCustomer?.id,
isAnonymousCustomer: _isAnonymousSelected,
initialValue: _validChangeResolution,
),
);
if (!mounted || result == null) return null;
setState(() => _changeResolution = result);
return result;
}
bool get _isAnonymousSelected {
if (_selectedCustomer == null || _anonymousCustomer == null) {
return _selectedCustomer == null;
@ -1098,6 +1133,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
void _onPaymentsChanged(List<InvoiceTransaction> payments) {
setState(() {
_payments = payments;
_changeResolution = null;
InvoiceTransaction? cash;
for (final p in payments) {
if (p.type == TransactionType.cashRegister) {
@ -2483,7 +2519,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
Future<void> _saveInvoice({
bool print = false,
Map<String, dynamic>? stockOverride,
bool changeConfirmed = false,
bool debtConfirmed = false,
}) async {
// اعتبارسنجی سبد خرید
@ -2548,37 +2583,10 @@ class _QuickSalesPageState extends State<QuickSalesPage>
if (!mounted || confirmed != true) return;
debtConfirmed = true;
}
if (_isOverpaid && !changeConfirmed) {
if (int.tryParse(_selectedCashRegisterId ?? '') == null) {
SnackBarHelper.show(
context,
message:
'برای ثبت مبلغ قابل برگشت، صندوق نقدی پیش‌فرض را انتخاب کنید.',
isError: true,
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('تأیید برگشت وجه'),
content: Text(
'${_formatNumber(_changeAmount)} $_paymentCurrencyUnit از صندوق نقدی به مشتری برگردانده شده است؟',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('انصراف'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('تأیید برگشت وجه'),
),
],
),
);
if (!mounted || confirmed != true) return;
changeConfirmed = true;
QuickSalesChangeResolution? changeResolution = _validChangeResolution;
if (_isOverpaid && changeResolution == null) {
changeResolution = await _openChangeResolutionDialog();
if (!mounted || changeResolution == null) return;
}
if (_shouldShareAfterSave) {
if (_shareOnlinePaymentEnabled) {
@ -2716,12 +2724,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
'tax': _totalTax,
'net': _totalAmount,
},
if (_isOverpaid)
'quick_sale_change': {
'amount': _changeAmount,
'cash_register_id': int.tryParse(_selectedCashRegisterId ?? ''),
'confirmed_returned': changeConfirmed,
},
if (_isOverpaid) 'quick_sale_change': changeResolution!.toJson(),
};
final gvSave = _parsedGlobalDiscountValue;
@ -2762,6 +2765,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
setState(() {
_cartItems.clear();
_payments = [];
_changeResolution = null;
_cashFollowsTotal = false;
_shareRemainingEnabled = false;
_checkoutDiscountExpanded = false;
@ -2855,7 +2859,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
await _saveInvoice(
print: print,
stockOverride: retryOverride,
changeConfirmed: changeConfirmed,
debtConfirmed: debtConfirmed,
);
}
@ -3580,7 +3583,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
initiallyExpanded: false,
initiallyExpanded: true,
children: [descWidget],
),
),
@ -3925,6 +3928,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
setState(() {
_selectedCustomer =
customer ?? _anonymousCustomer;
_changeResolution = null;
_syncShareChannelDefaults();
});
},
@ -4361,43 +4365,52 @@ class _QuickSalesPageState extends State<QuickSalesPage>
],
);
final desktopBody = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ستون اصلی: سبد خرید
Expanded(flex: 7, child: cartColumn),
// ستون کناری: مشتری، جمع و جزئیات سند
_buildDesktopCheckoutPanel(cs),
],
);
return _withParkedSalesBar(
Column(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
_autoCreatePaymentDocument
? Stack(
children: [
// ستون اصلی: سبد خرید
Expanded(flex: 7, child: cartColumn),
// ستون کناری: مشتری، جمع و جزئیات سند
_buildDesktopCheckoutPanel(cs),
Positioned.fill(child: desktopBody),
Align(
alignment: Alignment.bottomCenter,
child: QuickSalesPaymentDock(
key: ValueKey('payment-dock-$_activeSaleId'),
businessId: widget.businessId,
authStore: widget.authStore,
payments: _payments,
onChanged: _onPaymentsChanged,
invoiceTotal: _totalAmount,
changeResolution: _validChangeResolution,
onConfigureChange: _openChangeResolutionDialog,
defaultCashRegisterId: _selectedCashRegisterId,
currencyUnit: _paymentCurrencyUnit,
decimalPlaces: _invoiceCurrencyDecimalPlaces,
enabled: !_isSaving,
onSave: () => _saveInvoice(print: false),
onSaveAndPrint: () => _saveInvoice(print: true),
),
),
],
)
: Column(
children: [
Expanded(child: desktopBody),
Material(
elevation: 3,
color: cs.surface,
child: _buildCheckoutButtons(cs),
),
],
),
),
if (_autoCreatePaymentDocument)
QuickSalesPaymentDock(
key: ValueKey('payment-dock-$_activeSaleId'),
businessId: widget.businessId,
authStore: widget.authStore,
payments: _payments,
onChanged: _onPaymentsChanged,
invoiceTotal: _totalAmount,
defaultCashRegisterId: _selectedCashRegisterId,
currencyUnit: _paymentCurrencyUnit,
decimalPlaces: _invoiceCurrencyDecimalPlaces,
enabled: !_isSaving,
onSave: () => _saveInvoice(print: false),
onSaveAndPrint: () => _saveInvoice(print: true),
)
else
Material(
elevation: 3,
color: cs.surface,
child: _buildCheckoutButtons(cs),
),
],
),
compact: isCompact,
);
},
@ -4837,6 +4850,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer ?? _anonymousCustomer;
_changeResolution = null;
_syncShareChannelDefaults();
});
},
@ -5204,7 +5218,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
child: Scrollbar(
thumbVisibility: true,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
padding: EdgeInsets.fromLTRB(
12,
12,
12,
_autoCreatePaymentDocument ? 232 : 8,
),
child: _buildCheckoutScrollBody(
cs,
includeCustomer: true,
@ -5233,6 +5252,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
onCustomerChanged: (customer) {
setState(() {
_selectedCustomer = customer ?? _anonymousCustomer;
_changeResolution = null;
_syncShareChannelDefaults();
});
},
@ -5373,7 +5393,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey('qs-details-$_forceCheckoutDetails'),
initiallyExpanded: _forceCheckoutDetails,
initiallyExpanded: true,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
visualDensity: VisualDensity.compact,
@ -5578,6 +5598,38 @@ class _QuickSalesPageState extends State<QuickSalesPage>
calendarController: widget.calendarController,
isAnonymousCustomer: _isAnonymousSelected,
),
if (_isOverpaid) ...[
const SizedBox(height: 8),
Card(
color: cs.errorContainer,
margin: EdgeInsets.zero,
child: ListTile(
dense: true,
leading: Icon(
_validChangeResolution == null
? Icons.rule_folder_outlined
: Icons.edit_outlined,
color: cs.onErrorContainer,
),
title: Text(
_validChangeResolution == null
? 'تعیین تکلیف مبلغ اضافه‌دریافت'
: 'برگشت ${_formatNumber(_validChangeResolution!.returnedAmount)} • بستانکاری ${_formatNumber(_validChangeResolution!.creditAmount)}',
style: TextStyle(
color: cs.onErrorContainer,
fontWeight: FontWeight.w700,
),
),
subtitle: Text(
'${_formatNumber(_changeAmount)} $_paymentCurrencyUnit',
style: TextStyle(color: cs.onErrorContainer),
),
onTap: _isSaving
? null
: () => unawaited(_openChangeResolutionDialog()),
),
),
],
if (_remainingAmount > 0 && !_isOverpaid) ...[
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,

View file

@ -0,0 +1,519 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../models/person_model.dart';
import '../../models/quick_sales_change_resolution.dart';
import '../../services/person_service.dart';
import '../../utils/number_formatters.dart';
import '../../utils/number_normalizer.dart';
import '../invoice/bank_account_combobox_widget.dart';
import '../invoice/cash_register_combobox_widget.dart';
import '../invoice/person_combobox_widget.dart';
class QuickSalesChangeResolutionDialog extends StatefulWidget {
final int businessId;
final int? currencyId;
final num overpaymentAmount;
final String currencyUnit;
final int decimalPlaces;
final String customerName;
final int? customerId;
final bool isAnonymousCustomer;
final QuickSalesChangeResolution? initialValue;
const QuickSalesChangeResolutionDialog({
super.key,
required this.businessId,
required this.currencyId,
required this.overpaymentAmount,
required this.currencyUnit,
required this.decimalPlaces,
required this.customerName,
required this.customerId,
required this.isAnonymousCustomer,
this.initialValue,
});
@override
State<QuickSalesChangeResolutionDialog> createState() =>
_QuickSalesChangeResolutionDialogState();
}
class _QuickSalesChangeResolutionDialogState
extends State<QuickSalesChangeResolutionDialog> {
static const _uuid = Uuid();
final List<_ChangeAllocationDraft> _rows = [];
bool _creditRemainder = false;
num get _epsilon {
if (widget.decimalPlaces <= 0) return 0.5;
var value = 1.0;
for (var i = 0; i < widget.decimalPlaces; i++) {
value /= 10;
}
return value / 2;
}
num get _returned => _rows.fold<num>(0, (sum, row) => sum + row.amount);
num get _credit => _creditRemainder
? (widget.overpaymentAmount - _returned).clamp(
0,
widget.overpaymentAmount,
)
: 0;
num get _unresolved => (widget.overpaymentAmount - _returned - _credit).clamp(
0,
widget.overpaymentAmount,
);
bool get _isOverAllocated => _returned - widget.overpaymentAmount > _epsilon;
@override
void initState() {
super.initState();
final initial = widget.initialValue;
if (initial != null &&
(initial.overpaymentAmount - widget.overpaymentAmount).abs() <=
_epsilon) {
for (final allocation in initial.allocations) {
final row = _ChangeAllocationDraft.fromAllocation(
allocation,
decimalPlaces: widget.decimalPlaces,
);
_rows.add(row);
if (allocation.sourceType == QuickSalesChangeSourceType.person) {
unawaited(_loadPerson(row));
}
}
_creditRemainder = initial.creditAmount > _epsilon;
}
}
Future<void> _loadPerson(_ChangeAllocationDraft row) async {
try {
final person = await PersonService().getPerson(row.sourceId!);
if (!mounted || !_rows.contains(row)) return;
setState(() {
row.person = person;
row.sourceName = person.displayName;
});
} catch (_) {}
}
@override
void dispose() {
for (final row in _rows) {
row.dispose();
}
super.dispose();
}
String _format(num value) =>
formatWithThousands(value, decimalPlaces: widget.decimalPlaces);
void _addRow(QuickSalesChangeSourceType type) {
final available = (widget.overpaymentAmount - _returned).clamp(
0,
widget.overpaymentAmount,
);
final row = _ChangeAllocationDraft(
id: _uuid.v4(),
sourceType: type,
amount: available,
decimalPlaces: widget.decimalPlaces,
);
setState(() => _rows.add(row));
}
void _removeRow(_ChangeAllocationDraft row) {
setState(() => _rows.remove(row));
row.dispose();
}
String? _validationMessage() {
if (_isOverAllocated) {
return 'جمع مبالغ برگشتی از مبلغ اضافه‌دریافت بیشتر است.';
}
for (var i = 0; i < _rows.length; i++) {
final row = _rows[i];
if (row.sourceId == null || row.sourceId! <= 0) {
return 'منبع ردیف ${i + 1} را انتخاب کنید.';
}
if (row.sourceType == QuickSalesChangeSourceType.person &&
row.sourceId == widget.customerId) {
return 'مشتری فاکتور نمی‌تواند منبع برگشت وجه همان فاکتور باشد.';
}
if (row.amount <= _epsilon) {
return 'مبلغ ردیف ${i + 1} باید بیشتر از صفر باشد.';
}
if (row.commission < 0) {
return 'کارمزد بانکی نمی‌تواند منفی باشد.';
}
}
if (_unresolved > _epsilon) {
return 'هنوز ${_format(_unresolved)} ${widget.currencyUnit} تعیین تکلیف نشده است.';
}
if (_rows.isEmpty && _credit <= _epsilon) {
return 'حداقل یک روش برگشت یا بستانکاری مشتری را انتخاب کنید.';
}
return null;
}
void _submit() {
final validation = _validationMessage();
if (validation != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(validation), backgroundColor: Colors.red),
);
return;
}
Navigator.of(context).pop(
QuickSalesChangeResolution(
overpaymentAmount: widget.overpaymentAmount,
allocations: _rows
.map(
(row) => QuickSalesChangeAllocation(
id: row.id,
sourceType: row.sourceType,
sourceId: row.sourceId!,
sourceName: row.sourceName,
amount: row.amount,
commission: row.sourceType == QuickSalesChangeSourceType.bank
? row.commission
: 0,
),
)
.toList(),
creditAmount: _credit,
),
);
}
void _continueAsCustomerCredit() {
Navigator.of(context).pop(
QuickSalesChangeResolution(
overpaymentAmount: widget.overpaymentAmount,
allocations: const [],
creditAmount: widget.overpaymentAmount,
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final validation = _validationMessage();
return AlertDialog(
title: const Text('تعیین تکلیف اضافه‌دریافت'),
content: SizedBox(
width: 820,
height: 590,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Wrap(
alignment: WrapAlignment.spaceBetween,
runSpacing: 8,
children: [
Text('مشتری دریافت‌کننده: ${widget.customerName}'),
Text(
'اضافه‌دریافت: ${_format(widget.overpaymentAmount)} ${widget.currencyUnit}',
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
const Expanded(
child: Text(
'منابع وجه برگشتی',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
PopupMenuButton<QuickSalesChangeSourceType>(
tooltip: 'افزودن منبع برگشت',
onSelected: _addRow,
itemBuilder: (context) => QuickSalesChangeSourceType.values
.map(
(type) =>
PopupMenuItem(value: type, child: Text(type.label)),
)
.toList(),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 18, color: cs.onPrimary),
const SizedBox(width: 6),
Text(
'افزودن منبع',
style: TextStyle(color: cs.onPrimary),
),
],
),
),
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: _rows.isEmpty
? Center(
child: Text(
'برای پرداخت به مشتری، صندوق، بانک یا شخص را اضافه کنید.',
style: TextStyle(color: cs.onSurfaceVariant),
),
)
: ListView.separated(
itemCount: _rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_buildRow(context, _rows[index], index),
),
),
const Divider(),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _creditRemainder,
onChanged: widget.isAnonymousCustomer
? null
: (value) => setState(() => _creditRemainder = value),
title: const Text('باقی‌مانده به بستانکاری مشتری منتقل شود'),
subtitle: Text(
widget.isAnonymousCustomer
? 'برای مشتری ناشناس امکان ایجاد بستانکاری وجود ندارد.'
: 'مبلغ بستانکاری: ${_format(_credit)} ${widget.currencyUnit}',
),
),
Wrap(
spacing: 20,
runSpacing: 6,
children: [
Text('برگشت به مشتری: ${_format(_returned)}'),
Text('بستانکاری: ${_format(_credit)}'),
Text(
'تعیین‌تکلیف‌نشده: ${_format(_unresolved)}',
style: TextStyle(
color: validation == null ? cs.primary : cs.error,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('انصراف'),
),
if (!widget.isAnonymousCustomer)
TextButton.icon(
onPressed: _continueAsCustomerCredit,
icon: const Icon(Icons.account_balance_wallet_outlined),
label: const Text('ادامه بدون تعیین تکلیف'),
),
FilledButton.icon(
onPressed: validation == null ? _submit : null,
icon: const Icon(Icons.check),
label: const Text('تأیید تعیین تکلیف'),
),
],
);
}
Widget _buildRow(
BuildContext context,
_ChangeAllocationDraft row,
int index,
) {
final cs = Theme.of(context).colorScheme;
return Card.outlined(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: CircleAvatar(radius: 15, child: Text('${index + 1}')),
),
const SizedBox(width: 10),
Expanded(flex: 3, child: _buildSourcePicker(row)),
const SizedBox(width: 10),
Expanded(
flex: 2,
child: TextField(
controller: row.amountController,
textDirection: TextDirection.ltr,
textAlign: TextAlign.end,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
const EnglishDigitsFormatter(),
ThousandsSeparatorInputFormatter(
allowDecimal: widget.decimalPlaces > 0,
),
],
decoration: InputDecoration(
labelText: 'مبلغ برگشتی',
suffixText: widget.currencyUnit,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
if (row.sourceType == QuickSalesChangeSourceType.bank) ...[
const SizedBox(width: 10),
Expanded(
flex: 2,
child: TextField(
controller: row.commissionController,
textDirection: TextDirection.ltr,
textAlign: TextAlign.end,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
const EnglishDigitsFormatter(),
ThousandsSeparatorInputFormatter(
allowDecimal: widget.decimalPlaces > 0,
),
],
decoration: InputDecoration(
labelText: 'کارمزد انتقال',
suffixText: widget.currencyUnit,
helperText: 'هزینه جدا از مبلغ مشتری',
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
],
IconButton(
tooltip: 'حذف ردیف',
color: cs.error,
onPressed: () => _removeRow(row),
icon: const Icon(Icons.delete_outline),
),
],
),
),
);
}
Widget _buildSourcePicker(_ChangeAllocationDraft row) =>
switch (row.sourceType) {
QuickSalesChangeSourceType.cashRegister => CashRegisterComboboxWidget(
key: ValueKey('change-cash-${row.id}'),
businessId: widget.businessId,
selectedRegisterId: row.sourceId?.toString(),
filterCurrencyId: widget.currencyId,
dense: true,
label: 'صندوق پرداخت‌کننده',
isRequired: true,
onChanged: (option) => setState(() {
row.sourceId = int.tryParse(option?.id ?? '');
row.sourceName = option?.name ?? '';
}),
),
QuickSalesChangeSourceType.bank => BankAccountComboboxWidget(
key: ValueKey('change-bank-${row.id}'),
businessId: widget.businessId,
selectedAccountId: row.sourceId?.toString(),
filterCurrencyId: widget.currencyId,
dense: true,
label: 'بانک پرداخت‌کننده',
isRequired: true,
onChanged: (option) => setState(() {
row.sourceId = int.tryParse(option?.id ?? '');
row.sourceName = option?.name ?? '';
}),
),
QuickSalesChangeSourceType.person => PersonComboboxWidget(
key: ValueKey('change-person-${row.id}'),
businessId: widget.businessId,
selectedPerson: row.person,
dense: true,
showFinancialBalance: true,
label: 'شخص پرداخت‌کننده',
hintText: row.sourceName.isEmpty ? 'انتخاب شخص' : row.sourceName,
isRequired: true,
onChanged: (person) => setState(() {
row.person = person;
row.sourceId = person?.id;
row.sourceName = person?.displayName ?? '';
}),
),
};
}
class _ChangeAllocationDraft {
final String id;
final QuickSalesChangeSourceType sourceType;
int? sourceId;
String sourceName;
Person? person;
final TextEditingController amountController;
final TextEditingController commissionController;
_ChangeAllocationDraft({
required this.id,
required this.sourceType,
required num amount,
required int decimalPlaces,
this.sourceId,
this.sourceName = '',
num commission = 0,
}) : amountController = TextEditingController(
text: formatNumberForInput(amount, decimalPlaces: decimalPlaces),
),
commissionController = TextEditingController(
text: formatNumberForInput(commission, decimalPlaces: decimalPlaces),
);
factory _ChangeAllocationDraft.fromAllocation(
QuickSalesChangeAllocation allocation, {
required int decimalPlaces,
}) => _ChangeAllocationDraft(
id: allocation.id.isEmpty ? const Uuid().v4() : allocation.id,
sourceType: allocation.sourceType,
sourceId: allocation.sourceId,
sourceName: allocation.sourceName,
amount: allocation.amount,
commission: allocation.commission,
decimalPlaces: decimalPlaces,
);
num get amount => parseFormattedNumber(amountController.text) ?? 0;
num get commission => parseFormattedNumber(commissionController.text) ?? 0;
void dispose() {
amountController.dispose();
commissionController.dispose();
}
}

View file

@ -9,8 +9,10 @@ import '../../core/auth_store.dart';
import '../../models/invoice_transaction.dart';
import '../../models/person_model.dart';
import '../../models/quick_sales_payment_button.dart';
import '../../models/quick_sales_change_resolution.dart';
import '../../services/person_service.dart';
import '../../services/quick_sales_service.dart';
import '../../utils/date_formatters.dart';
import '../../utils/error_extractor.dart';
import '../../utils/number_formatters.dart';
import '../../utils/number_normalizer.dart';
@ -25,12 +27,15 @@ class QuickSalesPaymentDock extends StatefulWidget {
final List<InvoiceTransaction> payments;
final ValueChanged<List<InvoiceTransaction>> onChanged;
final num invoiceTotal;
final QuickSalesChangeResolution? changeResolution;
final Future<QuickSalesChangeResolution?> Function() onConfigureChange;
final String? defaultCashRegisterId;
final String currencyUnit;
final int decimalPlaces;
final bool enabled;
final VoidCallback onSave;
final VoidCallback onSaveAndPrint;
final QuickSalesService? service;
const QuickSalesPaymentDock({
super.key,
@ -39,12 +44,15 @@ class QuickSalesPaymentDock extends StatefulWidget {
required this.payments,
required this.onChanged,
required this.invoiceTotal,
required this.changeResolution,
required this.onConfigureChange,
required this.defaultCashRegisterId,
required this.currencyUnit,
required this.decimalPlaces,
required this.enabled,
required this.onSave,
required this.onSaveAndPrint,
this.service,
});
@override
@ -57,9 +65,13 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
static const double _minButtonWidth = 300;
static const double _minListWidth = 360;
static const double _summaryWidth = 384;
static const double _expandedHeight = 220;
static const double _collapsedHeight = 52;
static const double _handleOverlap = 12;
static const double _dockHeight = _expandedHeight + _handleOverlap;
static const int _maxPaymentLines = 50;
final QuickSalesService _service = QuickSalesService();
late final QuickSalesService _service;
final TextEditingController _amountController = TextEditingController();
final FocusNode _amountFocus = FocusNode();
final ScrollController _paymentScroll = ScrollController();
@ -67,6 +79,7 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
List<QuickSalesPaymentButtonModel> _buttons = const [];
bool _loadingButtons = true;
double _buttonWidth = _defaultButtonWidth;
bool _expanded = false;
num get _epsilon {
if (widget.decimalPlaces <= 0) return 0.5;
@ -88,10 +101,13 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
String get _preferenceKey =>
'quick_sales_payment_dock_buttons_width_v2_${widget.businessId}_${widget.authStore.currentUserId ?? 0}';
String get _expandedPreferenceKey =>
'quick_sales_payment_dock_expanded_v1_${widget.businessId}_${widget.authStore.currentUserId ?? 0}';
@override
void initState() {
super.initState();
_service = widget.service ?? QuickSalesService();
HardwareKeyboard.instance.addHandler(_handleShortcutKeyEvent);
_amountFocus.addListener(() {
if (_amountFocus.hasFocus) {
@ -104,6 +120,7 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
_syncAmount();
unawaited(_loadButtons());
unawaited(_loadWidth());
unawaited(_loadExpanded());
}
@override
@ -195,9 +212,25 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
await prefs.setDouble(_preferenceKey, _buttonWidth);
}
Future<void> _loadExpanded() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getBool(_expandedPreferenceKey);
if (mounted && value != null) setState(() => _expanded = value);
}
Future<void> _setExpanded(bool value) async {
if (_expanded == value) return;
setState(() => _expanded = value);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_expandedPreferenceKey, value);
}
String _format(num value) =>
formatWithThousands(value, decimalPlaces: widget.decimalPlaces);
String _formatPaymentDate(DateTime value) =>
DateFormatters.formatServerDateTime(value.toIso8601String());
void _addPayment(QuickSalesPaymentButtonModel button) {
if (!widget.enabled) return;
if (widget.payments.length >= _maxPaymentLines) {
@ -280,6 +313,15 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
_ => Icons.account_balance_wallet_outlined,
};
Color _paymentColor(TransactionType type, ColorScheme cs) => switch (type) {
TransactionType.cashRegister => Colors.teal,
TransactionType.bank => cs.primary,
TransactionType.person => cs.secondary,
TransactionType.check || TransactionType.checkExpense => Colors.orange,
TransactionType.pettyCash => Colors.indigo,
TransactionType.account => cs.tertiary,
};
IconData _buttonIcon(QuickSalesPaymentTargetType type) => switch (type) {
QuickSalesPaymentTargetType.cashRegister => Icons.payments_outlined,
QuickSalesPaymentTargetType.bank => Icons.account_balance,
@ -289,82 +331,171 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
elevation: 6,
color: cs.surface,
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 1050) {
return SizedBox(height: 260, child: _buildCompact(cs));
}
final maxButtons =
(constraints.maxWidth - _summaryWidth - _minListWidth - 12)
.clamp(_minButtonWidth, constraints.maxWidth * .62)
.toDouble();
final buttonWidth = _buttonWidth
.clamp(_minButtonWidth, maxButtons)
.toDouble();
return SizedBox(
height: 220,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: cs.outlineVariant)),
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 1050) {
return Material(
elevation: 6,
color: cs.surface,
child: SizedBox(height: 260, child: _buildCompact(cs)),
);
}
final maxButtons =
(constraints.maxWidth - _summaryWidth - _minListWidth - 12)
.clamp(_minButtonWidth, constraints.maxWidth * .62)
.toDouble();
final buttonWidth = _buttonWidth
.clamp(_minButtonWidth, maxButtons)
.toDouble();
return SizedBox(
height: _dockHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
bottom: 0,
width: _summaryWidth,
height: _expandedHeight,
child: Material(
elevation: 6,
color: cs.surfaceContainerLow,
shape: Border(
top: BorderSide(color: cs.outlineVariant),
right: BorderSide(color: cs.outlineVariant),
),
child: _buildSummary(cs),
),
),
child: Row(
textDirection: TextDirection.rtl,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: buttonWidth,
child: ColoredBox(
color: cs.surfaceContainerLow,
child: _buildAmountAndButtons(cs),
AnimatedPositioned(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
left: _summaryWidth,
right: 0,
bottom: _expanded ? 0 : -(_expandedHeight - _collapsedHeight),
height: _dockHeight,
child: Stack(
children: [
Positioned(
top: _handleOverlap,
left: 0,
right: 0,
height: _expandedHeight,
child: Material(
elevation: 6,
color: cs.surface,
shape: Border(
top: BorderSide(color: cs.outlineVariant),
),
child: Stack(
children: [
if (_expanded)
Positioned.fill(
child: Row(
textDirection: TextDirection.rtl,
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
SizedBox(
width: buttonWidth,
child: ColoredBox(
color: cs.surfaceContainerLow,
child: _buildAmountAndButtons(cs),
),
),
_ResizeHandle(
onReset: () {
setState(
() =>
_buttonWidth = _defaultButtonWidth
.clamp(
_minButtonWidth,
maxButtons,
)
.toDouble(),
);
unawaited(_saveWidth());
},
onDrag: (dx) {
setState(() {
_buttonWidth = (_buttonWidth - dx)
.clamp(
_minButtonWidth,
maxButtons,
)
.toDouble();
});
},
onDragEnd: () => unawaited(_saveWidth()),
onNudge: (dx) {
setState(() {
_buttonWidth = (_buttonWidth + dx)
.clamp(
_minButtonWidth,
maxButtons,
)
.toDouble();
});
unawaited(_saveWidth());
},
),
Expanded(child: _buildPaymentList(cs)),
],
),
)
else
Positioned(
top: 0,
left: 0,
right: 0,
height: _collapsedHeight,
child: _buildCollapsedBar(cs),
),
],
),
),
),
),
_ResizeHandle(
onReset: () {
setState(
() => _buttonWidth = _defaultButtonWidth
.clamp(_minButtonWidth, maxButtons)
.toDouble(),
);
unawaited(_saveWidth());
},
onDrag: (dx) {
setState(() {
_buttonWidth = (_buttonWidth - dx)
.clamp(_minButtonWidth, maxButtons)
.toDouble();
});
},
onDragEnd: () => unawaited(_saveWidth()),
onNudge: (dx) {
setState(() {
_buttonWidth = (_buttonWidth + dx)
.clamp(_minButtonWidth, maxButtons)
.toDouble();
});
unawaited(_saveWidth());
},
),
Expanded(child: _buildPaymentList(cs)),
VerticalDivider(
width: 1,
thickness: 1,
color: cs.outlineVariant,
),
SizedBox(
width: _summaryWidth,
child: ColoredBox(
color: cs.surfaceContainerLow,
child: _buildSummary(cs),
Align(
alignment: Alignment.topCenter,
child: _DockExpansionHandle(
expanded: _expanded,
onPressed: () => unawaited(_setExpanded(!_expanded)),
),
),
),
],
],
),
),
],
),
);
},
);
}
Widget _buildCollapsedBar(ColorScheme cs) {
return InkWell(
onTap: () => unawaited(_setExpanded(true)),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(18, 8, 18, 4),
child: Row(
textDirection: TextDirection.rtl,
children: [
Icon(Icons.account_balance_wallet_outlined, color: cs.primary),
const SizedBox(width: 8),
Text(
'پرداخت‌ها (${widget.payments.length})',
style: TextStyle(color: cs.primary, fontWeight: FontWeight.w700),
),
const Spacer(),
Text(
'مجموع: ${_format(_paid)} ${widget.currencyUnit}',
style: TextStyle(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
],
),
),
);
}
@ -458,17 +589,58 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
Divider(height: 1, color: cs.outlineVariant),
itemBuilder: (context, index) {
final payment = widget.payments[index];
final paymentColor = _paymentColor(payment.type, cs);
return SizedBox(
height: 58,
child: Row(
children: [
Icon(_paymentIcon(payment.type), color: cs.primary),
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: paymentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
),
alignment: Alignment.center,
child: Icon(
_paymentIcon(payment.type),
color: paymentColor,
size: 21,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_paymentTitle(payment),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_paymentTitle(payment),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.schedule_outlined,
size: 14,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
_formatPaymentDate(payment.transactionDate),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: cs.onSurfaceVariant),
),
),
],
),
],
),
),
SizedBox(
@ -506,12 +678,19 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
_updatePaymentAmount(payment, value),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'حذف پرداخت',
onPressed: widget.enabled
? () => _removePayment(payment.id)
: null,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
foregroundColor: cs.error,
backgroundColor: cs.errorContainer,
disabledForegroundColor: cs.onSurface.withValues(alpha: .3),
disabledBackgroundColor: cs.surfaceContainerHighest,
),
icon: const Icon(Icons.delete_outline),
),
],
),
@ -550,21 +729,44 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
if (_change > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(7),
),
child: Text(
'مبلغ قابل برگشت: ${_format(_change)} ${widget.currencyUnit}',
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onErrorContainer,
fontWeight: FontWeight.w700,
child: InkWell(
onTap: widget.enabled
? () => unawaited(widget.onConfigureChange())
: null,
borderRadius: BorderRadius.circular(7),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(7),
),
child: Row(
children: [
Expanded(
child: Text(
widget.changeResolution == null
? 'تعیین تکلیف ${_format(_change)} ${widget.currencyUnit}'
: 'برگشت ${_format(widget.changeResolution!.returnedAmount)} • بستانکاری ${_format(widget.changeResolution!.creditAmount)}',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onErrorContainer,
fontWeight: FontWeight.w700,
),
),
),
Icon(
widget.changeResolution == null
? Icons.rule_folder_outlined
: Icons.edit_outlined,
size: 18,
color: cs.onErrorContainer,
),
],
),
),
),
@ -744,6 +946,43 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
}
}
class _DockExpansionHandle extends StatelessWidget {
final bool expanded;
final VoidCallback onPressed;
const _DockExpansionHandle({required this.expanded, required this.onPressed});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Semantics(
button: true,
label: expanded ? 'جمع کردن بخش پرداخت' : 'باز کردن بخش پرداخت',
child: Material(
color: cs.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: cs.outlineVariant),
),
child: InkWell(
key: const ValueKey('quick-sales-payment-dock-toggle'),
onTap: onPressed,
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 54,
height: 24,
child: Icon(
expanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
size: 22,
color: cs.onSurfaceVariant,
),
),
),
),
);
}
}
class _ResizeHandle extends StatelessWidget {
final ValueChanged<double> onDrag;
final ValueChanged<double> onNudge;

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hesabix_ui/models/quick_sales_change_resolution.dart';
import 'package:hesabix_ui/widgets/quick_sales/quick_sales_change_resolution_dialog.dart';
void main() {
Future<void> openDialog(
WidgetTester tester, {
required bool isAnonymousCustomer,
required ValueChanged<QuickSalesChangeResolution?> onResult,
}) async {
await tester.binding.setSurfaceSize(const Size(1200, 900));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => FilledButton(
onPressed: () async {
final result = await showDialog<QuickSalesChangeResolution>(
context: context,
builder: (_) => QuickSalesChangeResolutionDialog(
businessId: 1,
currencyId: 1,
overpaymentAmount: 500000,
currencyUnit: 'ریال',
decimalPlaces: 0,
customerName: isAnonymousCustomer ? 'مشتری ناشناس' : 'زارع',
customerId: isAnonymousCustomer ? 1 : 2,
isAnonymousCustomer: isAnonymousCustomer,
),
);
onResult(result);
},
child: const Text('باز کردن'),
),
),
),
);
await tester.tap(find.text('باز کردن'));
await tester.pumpAndSettle();
}
testWidgets(
'identified customer can explicitly continue with full customer credit',
(tester) async {
QuickSalesChangeResolution? result;
await openDialog(
tester,
isAnonymousCustomer: false,
onResult: (value) => result = value,
);
expect(find.text('ادامه بدون تعیین تکلیف'), findsOneWidget);
await tester.tap(find.text('ادامه بدون تعیین تکلیف'));
await tester.pumpAndSettle();
expect(result, isNotNull);
expect(result!.returnedAmount, 0);
expect(result!.creditAmount, 500000);
expect(result!.allocations, isEmpty);
},
);
testWidgets('anonymous customer cannot continue without resolution', (
tester,
) async {
await openDialog(tester, isAnonymousCustomer: true, onResult: (_) {});
expect(find.text('ادامه بدون تعیین تکلیف'), findsNothing);
expect(find.text('تأیید تعیین تکلیف'), findsOneWidget);
await tester.tap(find.text('انصراف'));
await tester.pumpAndSettle();
});
}

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hesabix_ui/models/customer_model.dart';
import 'package:hesabix_ui/models/invoice_line_item.dart';
import 'package:hesabix_ui/models/quick_sales_parked_sale.dart';
import 'package:hesabix_ui/models/quick_sales_change_resolution.dart';
void main() {
test('parked sale round-trips cart, customer and totals', () {
@ -60,7 +61,10 @@ void main() {
});
test('bundle parse rejects wrong version and keeps active id', () {
expect(QuickSalesParkedSalesBundle.tryParse({'version': 2, 'sales': []}), isNull);
expect(
QuickSalesParkedSalesBundle.tryParse({'version': 2, 'sales': []}),
isNull,
);
final sale = QuickSalesParkedSale(
id: 'a',
createdAt: DateTime.now(),
@ -72,4 +76,42 @@ void main() {
expect(parsed?.activeId, 'a');
expect(parsed?.sales, hasLength(1));
});
test('parked sale preserves split change resolution', () {
final sale = QuickSalesParkedSale(
id: 'change-sale',
createdAt: DateTime.utc(2026, 10, 14, 8),
updatedAt: DateTime.utc(2026, 10, 14, 9),
documentDate: DateTime.utc(2026, 10, 14),
changeResolution: const QuickSalesChangeResolution(
overpaymentAmount: 1000000,
creditAmount: 300000,
allocations: [
QuickSalesChangeAllocation(
id: 'cash-1',
sourceType: QuickSalesChangeSourceType.cashRegister,
sourceId: 3,
sourceName: 'صندوق اصلی',
amount: 400000,
),
QuickSalesChangeAllocation(
id: 'bank-1',
sourceType: QuickSalesChangeSourceType.bank,
sourceId: 7,
sourceName: 'بانک سامان',
amount: 300000,
commission: 20000,
),
],
),
);
final parsed = QuickSalesParkedSale.fromJson(sale.toJson());
final resolution = parsed.changeResolution;
expect(resolution, isNotNull);
expect(resolution!.returnedAmount, 700000);
expect(resolution.creditAmount, 300000);
expect(resolution.bankCommission, 20000);
expect(resolution.isValidFor(1000000, 0.5), isTrue);
});
}

View file

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/models/invoice_transaction.dart';
import 'package:hesabix_ui/models/quick_sales_payment_button.dart';
import 'package:hesabix_ui/services/quick_sales_service.dart';
import 'package:hesabix_ui/widgets/quick_sales/quick_sales_payment_dock.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeQuickSalesService extends QuickSalesService {
@override
Future<List<QuickSalesPaymentButtonModel>> listPaymentButtons({
required int businessId,
}) async => const [];
}
void main() {
testWidgets('desktop payment panel collapses without hiding fixed summary', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
await tester.binding.setSurfaceSize(const Size(1400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
List<InvoiceTransaction>? changedPayments;
final payment = InvoiceTransaction(
id: 'payment-1',
type: TransactionType.cashRegister,
cashRegisterId: '7',
cashRegisterName: 'صندوق اصلی',
transactionDate: DateTime(2026, 10, 14, 16, 45),
amount: 3500000,
);
await tester.pumpWidget(
MaterialApp(
home: Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: QuickSalesPaymentDock(
businessId: 4952,
authStore: AuthStore(),
payments: [payment],
onChanged: (value) => changedPayments = value,
invoiceTotal: 2800000,
changeResolution: null,
onConfigureChange: () async => null,
defaultCashRegisterId: '7',
currencyUnit: 'ریال',
decimalPlaces: 0,
enabled: true,
onSave: () {},
onSaveAndPrint: () {},
service: _FakeQuickSalesService(),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('جمع کل'), findsOneWidget);
expect(find.text('پرداخت‌شده'), findsOneWidget);
expect(find.text('ثبت'), findsOneWidget);
expect(find.text('ثبت و چاپ'), findsOneWidget);
expect(find.text('پرداخت‌ها (1)'), findsOneWidget);
expect(find.text('دریافت در صندوق اصلی'), findsNothing);
final toggleFinder = find.byKey(
const ValueKey('quick-sales-payment-dock-toggle'),
);
final collapsedToggleRect = tester.getRect(toggleFinder);
await tester.tapAt(
Offset(collapsedToggleRect.center.dx, collapsedToggleRect.top + 2),
);
await tester.pumpAndSettle();
expect(find.text('دریافت در صندوق اصلی'), findsOneWidget);
expect(find.byIcon(Icons.schedule_outlined), findsOneWidget);
expect(find.byTooltip('حذف پرداخت'), findsOneWidget);
final typeIcon = tester.widget<Icon>(
find.byIcon(Icons.payments_outlined).first,
);
expect(typeIcon.color, Colors.teal);
final amountRect = tester.getRect(
find.byKey(const ValueKey('payment-payment-1')),
);
final deleteRect = tester.getRect(find.byTooltip('حذف پرداخت'));
final amountDeleteGap = amountRect.left >= deleteRect.right
? amountRect.left - deleteRect.right
: deleteRect.left - amountRect.right;
expect(amountDeleteGap, greaterThanOrEqualTo(8));
await tester.tap(find.byTooltip('حذف پرداخت'));
expect(changedPayments, isEmpty);
final expandedToggleRect = tester.getRect(toggleFinder);
await tester.tapAt(
Offset(expandedToggleRect.center.dx, expandedToggleRect.top + 2),
);
await tester.pumpAndSettle();
expect(find.text('دریافت در صندوق اصلی'), findsNothing);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getBool('quick_sales_payment_dock_expanded_v1_4952_0'),
isFalse,
);
});
}