feat(quick-sales): resolve overpayments by source #16
11 changed files with 1918 additions and 218 deletions
|
|
@ -10,8 +10,11 @@
|
||||||
- فشردن یک دکمه پرداخت، مبلغ داخل فیلد را به مقصد همان دکمه اضافه میکند.
|
- فشردن یک دکمه پرداخت، مبلغ داخل فیلد را به مقصد همان دکمه اضافه میکند.
|
||||||
- پس از افزودن پرداخت، فیلد مبلغ با مانده جدید بهروزرسانی میشود.
|
- پس از افزودن پرداخت، فیلد مبلغ با مانده جدید بهروزرسانی میشود.
|
||||||
- پرداختهای ثبتشده در یک فهرست مستقل و اسکرولدار نمایش داده میشوند. مبلغ هر ردیف قابل ویرایش و خود ردیف قابل حذف است.
|
- پرداختهای ثبتشده در یک فهرست مستقل و اسکرولدار نمایش داده میشوند. مبلغ هر ردیف قابل ویرایش و خود ردیف قابل حذف است.
|
||||||
|
- هر ردیف پرداخت، آیکون متناسب با نوع مقصد، تاریخ و ساعت ثبت و دکمه حذف قرمز دارد.
|
||||||
- کاربر میتواند مرز بین فضای دکمهها و فهرست پرداختها را با ماوس جابهجا کند. عرض انتخابشده برای همان کاربر و کسبوکار در مرورگر ذخیره میشود. دوبار کلیک روی مرز، عرض پیشفرض را برمیگرداند و کلیدهای جهت نیز مرز را حرکت میدهند.
|
- کاربر میتواند مرز بین فضای دکمهها و فهرست پرداختها را با ماوس جابهجا کند. عرض انتخابشده برای همان کاربر و کسبوکار در مرورگر ذخیره میشود. دوبار کلیک روی مرز، عرض پیشفرض را برمیگرداند و کلیدهای جهت نیز مرز را حرکت میدهند.
|
||||||
- جمع پرداختها اگر از مبلغ فاکتور بیشتر شود، مبلغ قابل برگشت با رنگ خطا نمایش داده میشود. خود فیلد مبلغ در این حالت صفر میماند.
|
- خلاصه فاکتور و دکمههای «ثبت» و «ثبت و چاپ» در پایین سمت چپ ثابت هستند. پنل سمت راست شامل مبلغ، دکمهها و فهرست پرداختها با فلش جمع یا باز میشود تا در حالت جمعشده فضای بیشتری به فهرست کالاها برسد. وضعیت انتخابشده برای همان کاربر و کسبوکار در مرورگر ذخیره میشود.
|
||||||
|
- «جزئیات سند» و بخش «شرح سند» در ستون سمت چپ بهصورت پیشفرض باز هستند.
|
||||||
|
- جمع پرداختها اگر از مبلغ فاکتور بیشتر شود، مبلغ اضافهدریافت با رنگ خطا نمایش داده میشود. با انتخاب آن، پنجره «تعیین تکلیف اضافهدریافت» باز میشود. خود فیلد مبلغ در این حالت صفر میماند.
|
||||||
- ثبت فاکتور بدون هیچ پرداختی، پس از تأیید کاربر، کل مبلغ را بدهی مشتری ثبت میکند.
|
- ثبت فاکتور بدون هیچ پرداختی، پس از تأیید کاربر، کل مبلغ را بدهی مشتری ثبت میکند.
|
||||||
|
|
||||||
## دکمههای پرداخت
|
## دکمههای پرداخت
|
||||||
|
|
@ -50,18 +53,34 @@
|
||||||
|
|
||||||
همه مقصدها در بکاند از نظر وجود، فعال بودن و تعلق به کسبوکار کنترل میشوند. شخص مقصد نیز نمیتواند همان مشتری فاکتور باشد.
|
همه مقصدها در بکاند از نظر وجود، فعال بودن و تعلق به کسبوکار کنترل میشوند. شخص مقصد نیز نمیتواند همان مشتری فاکتور باشد.
|
||||||
|
|
||||||
## ثبت حسابداری پرداخت اضافه
|
## تعیین تکلیف و ثبت حسابداری اضافهدریافت
|
||||||
|
|
||||||
اگر جمع دریافتها از خالص فاکتور بیشتر باشد، ثبت فقط پس از تأیید صریح برگشت وجه انجام میشود. صندوق نقدی انتخابشده باید فعال، متعلق به همان کسبوکار و همارز فاکتور باشد.
|
اگر جمع دریافتها از خالص فاکتور بیشتر باشد، تمام مبلغ اضافه باید پیش از ثبت تعیین تکلیف شود. کاربر میتواند مبلغ را بین چند ردیف از منابع زیر تقسیم کند:
|
||||||
|
|
||||||
|
1. **صندوق:** صندوق فعال و همارز فاکتور انتخاب میشود.
|
||||||
|
2. **بانک:** حساب بانکی فعال و همارز فاکتور همراه مبلغ کارمزد اختیاری انتخاب میشود.
|
||||||
|
3. **شخص:** هر شخص متعلق به کسبوکار بهجز خود مشتری فاکتور قابل انتخاب است. حساب دریافتنی `10401` او به مبلغ برگشتی بستانکار میشود؛ بنابراین ممکن است بدهی او کاهش یابد یا شخص از کسبوکار طلبکار شود.
|
||||||
|
4. **بستانکاری مشتری:** تمام یا بخشی از مبلغ به مشتری برگردانده نمیشود و بهعنوان بستانکاری او باقی میماند. زدن دکمه ثبت با وجود اضافهدریافت همیشه پنجره تعیین تکلیف را باز میکند. کاربر میتواند برای مشتری مشخص «ادامه بدون تعیین تکلیف» را بزند تا تمام اضافهدریافت به بستانکاری همان مشتری منتقل شود. این گزینه برای مشتری ناشناس نمایش داده نمیشود.
|
||||||
|
|
||||||
|
جمع مبالغ برگشتی و بستانکاری باید دقیقاً برابر اضافهدریافت باشد. کارمزد بانک جزو مبلغ دریافتی مشتری نیست و جداگانه محاسبه میشود.
|
||||||
|
|
||||||
در یک تراکنش دیتابیس این عملیات انجام میشود:
|
در یک تراکنش دیتابیس این عملیات انجام میشود:
|
||||||
|
|
||||||
1. فاکتور فروش ثبت میشود.
|
1. فاکتور فروش ثبت میشود.
|
||||||
2. برای هر ردیف پرداخت، سند دریافت مستقل ساخته میشود.
|
2. برای هر ردیف پرداخت، سند دریافت مستقل ساخته میشود.
|
||||||
3. برای مبلغ اضافه، یک سند پرداخت از صندوق به مشتری ساخته میشود.
|
3. برای مجموع مبلغی که واقعاً برگردانده شده، یک سند پرداخت با تمام منابع انتخابشده ساخته میشود.
|
||||||
4. شناسه سند برگشت وجه جدا از اسناد دریافت در `extra_info.links.quick_sale_change_payment_document_id` ذخیره میشود.
|
4. شناسه سند برگشت وجه جدا از اسناد دریافت در `extra_info.links.quick_sale_change_payment_document_id` ذخیره میشود.
|
||||||
|
|
||||||
مانده فاکتور از دریافت ناخالص منهای وجه برگشتی محاسبه میشود. در نتیجه فاکتوری که وجه اضافه آن به مشتری برگردانده شده، تسویهشده باقی میماند. هنگام ویرایش، تبدیل به پیشفاکتور یا حذف فاکتور، سند برگشت وجه نیز در همان تراکنش بهروزرسانی یا حذف میشود.
|
ثبتهای منبع وجه چنین هستند:
|
||||||
|
|
||||||
|
- صندوق: حساب صندوق به مبلغ برگشتی بستانکار میشود.
|
||||||
|
- بانک: حساب بانک به اندازه «مبلغ برگشتی + کارمزد» بستانکار و حساب `70902` با عنوان «کارمزد خدمات بانکی» به مبلغ کارمزد بدهکار میشود.
|
||||||
|
- شخص: حساب دریافتنی اشخاص `10401` برای شخص پرداختکننده بستانکار میشود. اگر مانده قبلی او بدهکار باشد، بدهی کاهش مییابد؛ اگر مبلغ برگشت بیشتر از بدهی باشد، شخص به همان میزان از کسبوکار طلبکار میشود.
|
||||||
|
- مشتری: حساب دریافتنی مشتری فقط به اندازه مبلغی که واقعاً پس داده شده بدهکار میشود؛ بخش پسدادهنشده بهصورت بستانکاری مشتری باقی میماند.
|
||||||
|
|
||||||
|
مانده فاکتور از دریافت ناخالص منهای مبلغی که واقعاً به مشتری برگردانده شده محاسبه میشود؛ کارمزد بانکی در این محاسبه دخالت ندارد. هنگام ویرایش، تبدیل به پیشفاکتور یا حذف فاکتور، سند برگشت وجه نیز در همان تراکنش بهروزرسانی یا حذف میشود.
|
||||||
|
|
||||||
|
تعیین تکلیف همراه هر فروش پارکشده در مرورگر ذخیره میشود. تغییر مبلغ پرداخت، مشتری یا مبلغ اضافهدریافت، تعیین تکلیف قبلی را نامعتبر میکند و هنگام ثبت، پنجره دوباره نمایش داده میشود. بستانکاری کامل مشتری نیز فقط پس از انتخاب صریح «ادامه بدون تعیین تکلیف» معتبر است.
|
||||||
|
|
||||||
## نکات سازگاری
|
## نکات سازگاری
|
||||||
|
|
||||||
|
|
@ -74,12 +93,15 @@
|
||||||
|
|
||||||
1. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون نقدی و ۶ میلیون بانکی ثبت میشود و مانده به صفر میرسد.
|
1. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون نقدی و ۶ میلیون بانکی ثبت میشود و مانده به صفر میرسد.
|
||||||
2. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون بانکی ثبت میشود و فیلد مبلغ ۶ میلیون را نشان میدهد.
|
2. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون بانکی ثبت میشود و فیلد مبلغ ۶ میلیون را نشان میدهد.
|
||||||
3. مبلغ فاکتور ۱۰ میلیون است؛ ۱۱ میلیون دریافت میشود، مبلغ برگشتی ۱ میلیون قرمز نمایش داده میشود و پس از تأیید، سند پرداخت ۱ میلیون از صندوق ساخته میشود.
|
3. مبلغ فاکتور ۱۰ میلیون است؛ ۱۱ میلیون دریافت میشود و ۶۰۰ هزار از صندوق و ۴۰۰ هزار از بانک به مشتری برگردانده میشود.
|
||||||
4. دو پرداخت به دو حساب بانکی متفاوت بهصورت دو ردیف مستقل باقی میمانند.
|
4. از یک میلیون اضافهدریافت، ۷۰۰ هزار به مشتری برگردانده و ۳۰۰ هزار به بستانکاری همان مشتری منتقل میشود.
|
||||||
5. پرداخت به شخص، آن شخص را بدهکار و مشتری فاکتور را به همان مبلغ بستانکار میکند.
|
5. یک میلیون از بانک به مشتری برگردانده و ۲۰ هزار کارمزد ثبت میشود؛ بانک ۱٬۰۲۰٬۰۰۰ بستانکار، حساب `70902` بیست هزار بدهکار و مانده فاکتور فقط یک میلیون تعدیل میشود.
|
||||||
6. حذف یا ویرایش فاکتور دارای وجه برگشتی، اسناد دریافت و برگشت مرتبط را نیز هماهنگ میکند.
|
6. برگشت از شخص حتی بیشتر از بدهی قبلی او مجاز است و مازاد، شخص را در حساب `10401` بستانکار میکند.
|
||||||
7. کاربر عرض بخش دکمهها را تغییر میدهد و پس از بازکردن دوباره صفحه همان عرض بازیابی میشود.
|
7. دو پرداخت به دو حساب بانکی متفاوت بهصورت دو ردیف مستقل باقی میمانند.
|
||||||
8. میانبر تعریفشده، مبلغ جاری فیلد را به مقصد همان دکمه اضافه میکند.
|
8. پرداخت به شخص، آن شخص را بدهکار و مشتری فاکتور را به همان مبلغ بستانکار میکند.
|
||||||
|
9. حذف یا ویرایش فاکتور دارای وجه برگشتی، اسناد دریافت و برگشت مرتبط را نیز هماهنگ میکند.
|
||||||
|
10. کاربر عرض بخش دکمهها را تغییر میدهد و پس از بازکردن دوباره صفحه همان عرض بازیابی میشود.
|
||||||
|
11. میانبر تعریفشده، مبلغ جاری فیلد را به مقصد همان دکمه اضافه میکند.
|
||||||
|
|
||||||
## بررسیهای خودکار
|
## بررسیهای خودکار
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2775,6 +2775,18 @@ def _quick_sale_change_amount(document: Document, payments: List[Dict[str, Any]]
|
||||||
return max(Decimal(0), paid - total)
|
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(
|
def _create_quick_sale_change_payment(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2785,15 +2797,24 @@ def _create_quick_sale_change_payment(
|
||||||
payments: List[Dict[str, Any]],
|
payments: List[Dict[str, Any]],
|
||||||
change_request: Dict[str, Any] | None,
|
change_request: Dict[str, Any] | None,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
"""ثبت وجه برگشتی فروش سریع از صندوق، در همان تراکنش ساخت فاکتور."""
|
"""ثبت تعیین تکلیف اضافهدریافت فروش سریع در همان تراکنش فاکتور."""
|
||||||
change_amount = _quick_sale_change_amount(document, payments)
|
change_amount = _quick_sale_change_amount(document, payments)
|
||||||
if change_amount <= Decimal("0.01"):
|
if change_amount <= Decimal("0.01"):
|
||||||
return None
|
return None
|
||||||
request = dict(change_request) if isinstance(change_request, dict) else {}
|
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(
|
raise ApiError(
|
||||||
"QUICK_SALE_CHANGE_CONFIRMATION_REQUIRED",
|
"QUICK_SALE_CHANGE_CONFIRMATION_REQUIRED",
|
||||||
"باید پرداخت مبلغ قابل برگشت به مشتری تأیید شود",
|
"باید تعیین تکلیف مبلغ اضافهدریافت تأیید شود",
|
||||||
http_status=400,
|
http_status=400,
|
||||||
)
|
)
|
||||||
requested_amount = Decimal(str(request.get("amount", 0) or 0))
|
requested_amount = Decimal(str(request.get("amount", 0) or 0))
|
||||||
|
|
@ -2803,18 +2824,123 @@ def _create_quick_sale_change_payment(
|
||||||
"مبلغ قابل برگشت با اضافهپرداخت فاکتور همخوانی ندارد",
|
"مبلغ قابل برگشت با اضافهپرداخت فاکتور همخوانی ندارد",
|
||||||
http_status=400,
|
http_status=400,
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
cash_register_id = int(request.get("cash_register_id"))
|
allocations = request.get("allocations")
|
||||||
except (TypeError, ValueError) as exc:
|
# سازگاری با کلاینت قبلی: تمام مبلغ از یک صندوق برگردانده میشد.
|
||||||
|
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(
|
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,
|
http_status=400,
|
||||||
) from exc
|
) from exc
|
||||||
|
if credited_amount < 0:
|
||||||
|
raise ApiError(
|
||||||
|
"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 = (
|
register = (
|
||||||
db.query(CashRegister)
|
db.query(CashRegister)
|
||||||
.filter(
|
.filter(
|
||||||
CashRegister.id == cash_register_id,
|
CashRegister.id == source_id,
|
||||||
CashRegister.business_id == int(business_id),
|
CashRegister.business_id == int(business_id),
|
||||||
CashRegister.is_active == True, # noqa: E712
|
CashRegister.is_active == True, # noqa: E712
|
||||||
)
|
)
|
||||||
|
|
@ -2832,6 +2958,101 @@ def _create_quick_sale_change_payment(
|
||||||
"ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست",
|
"ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست",
|
||||||
http_status=400,
|
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
|
from app.services.receipt_payment_service import create_receipt_payment
|
||||||
|
|
||||||
rp = create_receipt_payment(
|
rp = create_receipt_payment(
|
||||||
|
|
@ -2846,17 +3067,17 @@ def _create_quick_sale_change_payment(
|
||||||
"description": f"برگشت اضافهدریافت فروش سریع فاکتور {document.code}",
|
"description": f"برگشت اضافهدریافت فروش سریع فاکتور {document.code}",
|
||||||
"person_lines": [{
|
"person_lines": [{
|
||||||
"person_id": int(person_id),
|
"person_id": int(person_id),
|
||||||
"amount": float(change_amount),
|
"amount": float(returned_amount),
|
||||||
"description": f"وجه برگشتی فاکتور {document.code}",
|
"description": f"وجه برگشتی فاکتور {document.code}",
|
||||||
|
"extra_info": {"invoice_id": int(document.id)},
|
||||||
}],
|
}],
|
||||||
"account_lines": [{
|
"account_lines": account_lines,
|
||||||
"transaction_type": "cash_register",
|
"fx_adjustment_lines": ([{
|
||||||
"cash_register_id": cash_register_id,
|
"account_code": "70902",
|
||||||
"cash_register_name": register.name,
|
"side": "debit",
|
||||||
"amount": float(change_amount),
|
"amount": float(total_bank_commission),
|
||||||
"transaction_date": document.document_date.isoformat(),
|
"description": "کارمزد خدمات بانکی برگشت وجه فروش سریع",
|
||||||
"description": f"برگشت اضافهدریافت فاکتور {document.code}",
|
}] if total_bank_commission > 0 else []),
|
||||||
}],
|
|
||||||
"extra_info": {
|
"extra_info": {
|
||||||
"source": "quick_sale_change",
|
"source": "quick_sale_change",
|
||||||
"invoice_id": int(document.id),
|
"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
|
continue
|
||||||
processed_doc_ids.add(doc_id_int)
|
processed_doc_ids.add(doc_id_int)
|
||||||
if (rp_doc.extra_info or {}).get("source") == "quick_sale_change":
|
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:
|
else:
|
||||||
total_paid += _sum_receipt_doc_payments_for_invoice(
|
total_paid += _sum_receipt_doc_payments_for_invoice(
|
||||||
rp_doc, inv_id, via_person_line=True
|
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)
|
change_doc = rp_docs_by_id.get(parsed_change_doc_id)
|
||||||
if change_doc:
|
if change_doc:
|
||||||
processed_doc_ids.add(parsed_change_doc_id)
|
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
|
remaining = total_amount - total_paid
|
||||||
results[inv_id] = {
|
results[inv_id] = {
|
||||||
|
|
@ -10801,7 +11027,7 @@ def calculate_invoice_remaining(
|
||||||
continue
|
continue
|
||||||
processed_doc_ids.add(doc.id)
|
processed_doc_ids.add(doc.id)
|
||||||
if (doc.extra_info or {}).get("source") == "quick_sale_change":
|
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:
|
else:
|
||||||
total_paid += _sum_receipt_doc_payments_for_invoice(
|
total_paid += _sum_receipt_doc_payments_for_invoice(
|
||||||
doc, invoice_id, via_person_line=True
|
doc, invoice_id, via_person_line=True
|
||||||
|
|
@ -10825,7 +11051,7 @@ def calculate_invoice_remaining(
|
||||||
)
|
)
|
||||||
if change_doc:
|
if change_doc:
|
||||||
processed_doc_ids.add(parsed_change_doc_id)
|
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
|
remaining = total_amount - total_paid
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,58 @@ from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
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):
|
def _invoice(total: int):
|
||||||
return SimpleNamespace(extra_info={"totals": {"net": total}})
|
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:
|
def test_quick_sale_change_is_zero_when_payments_do_not_exceed_total() -> None:
|
||||||
assert _quick_sale_change_amount(
|
assert _quick_sale_change_amount(
|
||||||
_invoice(1_000_000),
|
_invoice(1_000_000),
|
||||||
|
|
@ -28,3 +73,227 @@ def test_quick_sale_change_uses_settlement_amount_for_foreign_payment() -> None:
|
||||||
_invoice(1_000_000),
|
_invoice(1_000_000),
|
||||||
[{"amount": 10, "settles_amount": 1_100_000}],
|
[{"amount": 10, "settles_amount": 1_100_000}],
|
||||||
) == 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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import '../core/date_utils.dart';
|
||||||
import 'customer_model.dart';
|
import 'customer_model.dart';
|
||||||
import 'invoice_line_item.dart';
|
import 'invoice_line_item.dart';
|
||||||
import 'invoice_transaction.dart';
|
import 'invoice_transaction.dart';
|
||||||
|
import 'quick_sales_change_resolution.dart';
|
||||||
|
|
||||||
/// یک فروش باز در صندوق فروش سریع (Park / Hold).
|
/// یک فروش باز در صندوق فروش سریع (Park / Hold).
|
||||||
///
|
///
|
||||||
|
|
@ -34,6 +35,7 @@ class QuickSalesParkedSale {
|
||||||
final int shareExpiryHours;
|
final int shareExpiryHours;
|
||||||
final int itemCount;
|
final int itemCount;
|
||||||
final num totalAmount;
|
final num totalAmount;
|
||||||
|
final QuickSalesChangeResolution? changeResolution;
|
||||||
|
|
||||||
const QuickSalesParkedSale({
|
const QuickSalesParkedSale({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -58,6 +60,7 @@ class QuickSalesParkedSale {
|
||||||
this.shareExpiryHours = 168,
|
this.shareExpiryHours = 168,
|
||||||
this.itemCount = 0,
|
this.itemCount = 0,
|
||||||
this.totalAmount = 0,
|
this.totalAmount = 0,
|
||||||
|
this.changeResolution,
|
||||||
});
|
});
|
||||||
|
|
||||||
static String newId() => const Uuid().v4();
|
static String newId() => const Uuid().v4();
|
||||||
|
|
@ -104,6 +107,8 @@ class QuickSalesParkedSale {
|
||||||
int? shareExpiryHours,
|
int? shareExpiryHours,
|
||||||
int? itemCount,
|
int? itemCount,
|
||||||
num? totalAmount,
|
num? totalAmount,
|
||||||
|
QuickSalesChangeResolution? changeResolution,
|
||||||
|
bool clearChangeResolution = false,
|
||||||
}) {
|
}) {
|
||||||
return QuickSalesParkedSale(
|
return QuickSalesParkedSale(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -130,6 +135,9 @@ class QuickSalesParkedSale {
|
||||||
shareExpiryHours: shareExpiryHours ?? this.shareExpiryHours,
|
shareExpiryHours: shareExpiryHours ?? this.shareExpiryHours,
|
||||||
itemCount: itemCount ?? this.itemCount,
|
itemCount: itemCount ?? this.itemCount,
|
||||||
totalAmount: totalAmount ?? this.totalAmount,
|
totalAmount: totalAmount ?? this.totalAmount,
|
||||||
|
changeResolution: clearChangeResolution
|
||||||
|
? null
|
||||||
|
: changeResolution ?? this.changeResolution,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +165,8 @@ class QuickSalesParkedSale {
|
||||||
'share_expiry_hours': shareExpiryHours,
|
'share_expiry_hours': shareExpiryHours,
|
||||||
'item_count': itemCount,
|
'item_count': itemCount,
|
||||||
'total_amount': totalAmount,
|
'total_amount': totalAmount,
|
||||||
|
if (changeResolution != null)
|
||||||
|
'change_resolution': changeResolution!.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +219,8 @@ class QuickSalesParkedSale {
|
||||||
json['cash_follows_total'] != false && (payments.length <= 1),
|
json['cash_follows_total'] != false && (payments.length <= 1),
|
||||||
cashRegisterId: json['cash_register_id']?.toString(),
|
cashRegisterId: json['cash_register_id']?.toString(),
|
||||||
warehouseId: _asInt(json['warehouse_id']),
|
warehouseId: _asInt(json['warehouse_id']),
|
||||||
documentDate: HesabixDateUtils.parseApiDate(
|
documentDate:
|
||||||
|
HesabixDateUtils.parseApiDate(
|
||||||
json['document_date'],
|
json['document_date'],
|
||||||
rawValue: json['document_date_raw'],
|
rawValue: json['document_date_raw'],
|
||||||
) ??
|
) ??
|
||||||
|
|
@ -226,6 +237,11 @@ class QuickSalesParkedSale {
|
||||||
shareExpiryHours: _asInt(json['share_expiry_hours']) ?? 168,
|
shareExpiryHours: _asInt(json['share_expiry_hours']) ?? 168,
|
||||||
itemCount: _asInt(json['item_count']) ?? items.length,
|
itemCount: _asInt(json['item_count']) ?? items.length,
|
||||||
totalAmount: _asNum(json['total_amount']) ?? 0,
|
totalAmount: _asNum(json['total_amount']) ?? 0,
|
||||||
|
changeResolution: json['change_resolution'] is Map
|
||||||
|
? QuickSalesChangeResolution.fromJson(
|
||||||
|
Map<String, dynamic>.from(json['change_resolution'] as Map),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,12 @@ import '../../services/currency_service.dart';
|
||||||
import '../../models/invoice_line_item.dart';
|
import '../../models/invoice_line_item.dart';
|
||||||
import '../../models/invoice_transaction.dart';
|
import '../../models/invoice_transaction.dart';
|
||||||
import '../../models/quick_sales_parked_sale.dart';
|
import '../../models/quick_sales_parked_sale.dart';
|
||||||
|
import '../../models/quick_sales_change_resolution.dart';
|
||||||
import '../../widgets/invoice/customer_combobox_widget.dart';
|
import '../../widgets/invoice/customer_combobox_widget.dart';
|
||||||
import '../../widgets/quick_sales/quick_sales_parked_sales_bar.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_composer.dart';
|
||||||
import '../../widgets/quick_sales/quick_sales_payment_dock.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 '../../widgets/invoice/warehouse_combobox_widget.dart';
|
||||||
import '../../utils/currency_display_utils.dart';
|
import '../../utils/currency_display_utils.dart';
|
||||||
import '../../widgets/permission/access_denied_page.dart';
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
@ -157,6 +159,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
|
|
||||||
// پرداخت
|
// پرداخت
|
||||||
List<InvoiceTransaction> _payments = [];
|
List<InvoiceTransaction> _payments = [];
|
||||||
|
QuickSalesChangeResolution? _changeResolution;
|
||||||
String? _selectedCashRegisterId;
|
String? _selectedCashRegisterId;
|
||||||
bool _autoCreatePaymentDocument = true;
|
bool _autoCreatePaymentDocument = true;
|
||||||
bool _cashFollowsTotal = false;
|
bool _cashFollowsTotal = false;
|
||||||
|
|
@ -658,6 +661,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
shareExpiryHours: _shareExpiryHours,
|
shareExpiryHours: _shareExpiryHours,
|
||||||
itemCount: _cartItems.length,
|
itemCount: _cartItems.length,
|
||||||
totalAmount: _totalAmount,
|
totalAmount: _totalAmount,
|
||||||
|
changeResolution: _validChangeResolution,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,6 +676,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_selectedCustomer = customer;
|
_selectedCustomer = customer;
|
||||||
_cartItems = sale.cartItems.map(QuickSalesParkedSale.cloneLine).toList();
|
_cartItems = sale.cartItems.map(QuickSalesParkedSale.cloneLine).toList();
|
||||||
_payments = List<InvoiceTransaction>.from(sale.payments);
|
_payments = List<InvoiceTransaction>.from(sale.payments);
|
||||||
|
_changeResolution = sale.changeResolution;
|
||||||
_cashFollowsTotal = sale.cashFollowsTotal;
|
_cashFollowsTotal = sale.cashFollowsTotal;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_checkoutDiscountExpanded = false;
|
_checkoutDiscountExpanded = false;
|
||||||
|
|
@ -699,6 +704,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_selectedCustomer = _anonymousCustomer;
|
_selectedCustomer = _anonymousCustomer;
|
||||||
_cartItems = [];
|
_cartItems = [];
|
||||||
_payments = [];
|
_payments = [];
|
||||||
|
_changeResolution = null;
|
||||||
_cashFollowsTotal = false;
|
_cashFollowsTotal = false;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_checkoutDiscountExpanded = false;
|
_checkoutDiscountExpanded = false;
|
||||||
|
|
@ -1043,6 +1049,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_autoCreatePaymentDocument = value;
|
_autoCreatePaymentDocument = value;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_payments = [];
|
_payments = [];
|
||||||
|
_changeResolution = null;
|
||||||
_cashFollowsTotal = false;
|
_cashFollowsTotal = false;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
_syncShareChannelDefaults();
|
_syncShareChannelDefaults();
|
||||||
|
|
@ -1075,6 +1082,34 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
|
|
||||||
num get _changeAmount => _isOverpaid ? _paidAmount - _totalAmount : 0;
|
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 {
|
bool get _isAnonymousSelected {
|
||||||
if (_selectedCustomer == null || _anonymousCustomer == null) {
|
if (_selectedCustomer == null || _anonymousCustomer == null) {
|
||||||
return _selectedCustomer == null;
|
return _selectedCustomer == null;
|
||||||
|
|
@ -1098,6 +1133,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
void _onPaymentsChanged(List<InvoiceTransaction> payments) {
|
void _onPaymentsChanged(List<InvoiceTransaction> payments) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_payments = payments;
|
_payments = payments;
|
||||||
|
_changeResolution = null;
|
||||||
InvoiceTransaction? cash;
|
InvoiceTransaction? cash;
|
||||||
for (final p in payments) {
|
for (final p in payments) {
|
||||||
if (p.type == TransactionType.cashRegister) {
|
if (p.type == TransactionType.cashRegister) {
|
||||||
|
|
@ -2483,7 +2519,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
Future<void> _saveInvoice({
|
Future<void> _saveInvoice({
|
||||||
bool print = false,
|
bool print = false,
|
||||||
Map<String, dynamic>? stockOverride,
|
Map<String, dynamic>? stockOverride,
|
||||||
bool changeConfirmed = false,
|
|
||||||
bool debtConfirmed = false,
|
bool debtConfirmed = false,
|
||||||
}) async {
|
}) async {
|
||||||
// اعتبارسنجی سبد خرید
|
// اعتبارسنجی سبد خرید
|
||||||
|
|
@ -2548,37 +2583,10 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
if (!mounted || confirmed != true) return;
|
if (!mounted || confirmed != true) return;
|
||||||
debtConfirmed = true;
|
debtConfirmed = true;
|
||||||
}
|
}
|
||||||
if (_isOverpaid && !changeConfirmed) {
|
QuickSalesChangeResolution? changeResolution = _validChangeResolution;
|
||||||
if (int.tryParse(_selectedCashRegisterId ?? '') == null) {
|
if (_isOverpaid && changeResolution == null) {
|
||||||
SnackBarHelper.show(
|
changeResolution = await _openChangeResolutionDialog();
|
||||||
context,
|
if (!mounted || changeResolution == null) return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (_shouldShareAfterSave) {
|
if (_shouldShareAfterSave) {
|
||||||
if (_shareOnlinePaymentEnabled) {
|
if (_shareOnlinePaymentEnabled) {
|
||||||
|
|
@ -2716,12 +2724,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
'tax': _totalTax,
|
'tax': _totalTax,
|
||||||
'net': _totalAmount,
|
'net': _totalAmount,
|
||||||
},
|
},
|
||||||
if (_isOverpaid)
|
if (_isOverpaid) 'quick_sale_change': changeResolution!.toJson(),
|
||||||
'quick_sale_change': {
|
|
||||||
'amount': _changeAmount,
|
|
||||||
'cash_register_id': int.tryParse(_selectedCashRegisterId ?? ''),
|
|
||||||
'confirmed_returned': changeConfirmed,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final gvSave = _parsedGlobalDiscountValue;
|
final gvSave = _parsedGlobalDiscountValue;
|
||||||
|
|
@ -2762,6 +2765,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
setState(() {
|
setState(() {
|
||||||
_cartItems.clear();
|
_cartItems.clear();
|
||||||
_payments = [];
|
_payments = [];
|
||||||
|
_changeResolution = null;
|
||||||
_cashFollowsTotal = false;
|
_cashFollowsTotal = false;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_checkoutDiscountExpanded = false;
|
_checkoutDiscountExpanded = false;
|
||||||
|
|
@ -2855,7 +2859,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
await _saveInvoice(
|
await _saveInvoice(
|
||||||
print: print,
|
print: print,
|
||||||
stockOverride: retryOverride,
|
stockOverride: retryOverride,
|
||||||
changeConfirmed: changeConfirmed,
|
|
||||||
debtConfirmed: debtConfirmed,
|
debtConfirmed: debtConfirmed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3580,7 +3583,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
initiallyExpanded: false,
|
initiallyExpanded: true,
|
||||||
children: [descWidget],
|
children: [descWidget],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -3925,6 +3928,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCustomer =
|
_selectedCustomer =
|
||||||
customer ?? _anonymousCustomer;
|
customer ?? _anonymousCustomer;
|
||||||
|
_changeResolution = null;
|
||||||
_syncShareChannelDefaults();
|
_syncShareChannelDefaults();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -4361,11 +4365,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return _withParkedSalesBar(
|
final desktopBody = Row(
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ستون اصلی: سبد خرید
|
// ستون اصلی: سبد خرید
|
||||||
|
|
@ -4373,24 +4373,37 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
// ستون کناری: مشتری، جمع و جزئیات سند
|
// ستون کناری: مشتری، جمع و جزئیات سند
|
||||||
_buildDesktopCheckoutPanel(cs),
|
_buildDesktopCheckoutPanel(cs),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
|
||||||
if (_autoCreatePaymentDocument)
|
return _withParkedSalesBar(
|
||||||
QuickSalesPaymentDock(
|
_autoCreatePaymentDocument
|
||||||
|
? Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: desktopBody),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: QuickSalesPaymentDock(
|
||||||
key: ValueKey('payment-dock-$_activeSaleId'),
|
key: ValueKey('payment-dock-$_activeSaleId'),
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
authStore: widget.authStore,
|
authStore: widget.authStore,
|
||||||
payments: _payments,
|
payments: _payments,
|
||||||
onChanged: _onPaymentsChanged,
|
onChanged: _onPaymentsChanged,
|
||||||
invoiceTotal: _totalAmount,
|
invoiceTotal: _totalAmount,
|
||||||
|
changeResolution: _validChangeResolution,
|
||||||
|
onConfigureChange: _openChangeResolutionDialog,
|
||||||
defaultCashRegisterId: _selectedCashRegisterId,
|
defaultCashRegisterId: _selectedCashRegisterId,
|
||||||
currencyUnit: _paymentCurrencyUnit,
|
currencyUnit: _paymentCurrencyUnit,
|
||||||
decimalPlaces: _invoiceCurrencyDecimalPlaces,
|
decimalPlaces: _invoiceCurrencyDecimalPlaces,
|
||||||
enabled: !_isSaving,
|
enabled: !_isSaving,
|
||||||
onSave: () => _saveInvoice(print: false),
|
onSave: () => _saveInvoice(print: false),
|
||||||
onSaveAndPrint: () => _saveInvoice(print: true),
|
onSaveAndPrint: () => _saveInvoice(print: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
else
|
: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: desktopBody),
|
||||||
Material(
|
Material(
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
color: cs.surface,
|
color: cs.surface,
|
||||||
|
|
@ -4837,6 +4850,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
onCustomerChanged: (customer) {
|
onCustomerChanged: (customer) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCustomer = customer ?? _anonymousCustomer;
|
_selectedCustomer = customer ?? _anonymousCustomer;
|
||||||
|
_changeResolution = null;
|
||||||
_syncShareChannelDefaults();
|
_syncShareChannelDefaults();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -5204,7 +5218,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
_autoCreatePaymentDocument ? 232 : 8,
|
||||||
|
),
|
||||||
child: _buildCheckoutScrollBody(
|
child: _buildCheckoutScrollBody(
|
||||||
cs,
|
cs,
|
||||||
includeCustomer: true,
|
includeCustomer: true,
|
||||||
|
|
@ -5233,6 +5252,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
onCustomerChanged: (customer) {
|
onCustomerChanged: (customer) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCustomer = customer ?? _anonymousCustomer;
|
_selectedCustomer = customer ?? _anonymousCustomer;
|
||||||
|
_changeResolution = null;
|
||||||
_syncShareChannelDefaults();
|
_syncShareChannelDefaults();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -5373,7 +5393,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
key: ValueKey('qs-details-$_forceCheckoutDetails'),
|
key: ValueKey('qs-details-$_forceCheckoutDetails'),
|
||||||
initiallyExpanded: _forceCheckoutDetails,
|
initiallyExpanded: true,
|
||||||
tilePadding: EdgeInsets.zero,
|
tilePadding: EdgeInsets.zero,
|
||||||
childrenPadding: const EdgeInsets.only(bottom: 8),
|
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|
@ -5578,6 +5598,38 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
isAnonymousCustomer: _isAnonymousSelected,
|
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) ...[
|
if (_remainingAmount > 0 && !_isOverpaid) ...[
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,10 @@ import '../../core/auth_store.dart';
|
||||||
import '../../models/invoice_transaction.dart';
|
import '../../models/invoice_transaction.dart';
|
||||||
import '../../models/person_model.dart';
|
import '../../models/person_model.dart';
|
||||||
import '../../models/quick_sales_payment_button.dart';
|
import '../../models/quick_sales_payment_button.dart';
|
||||||
|
import '../../models/quick_sales_change_resolution.dart';
|
||||||
import '../../services/person_service.dart';
|
import '../../services/person_service.dart';
|
||||||
import '../../services/quick_sales_service.dart';
|
import '../../services/quick_sales_service.dart';
|
||||||
|
import '../../utils/date_formatters.dart';
|
||||||
import '../../utils/error_extractor.dart';
|
import '../../utils/error_extractor.dart';
|
||||||
import '../../utils/number_formatters.dart';
|
import '../../utils/number_formatters.dart';
|
||||||
import '../../utils/number_normalizer.dart';
|
import '../../utils/number_normalizer.dart';
|
||||||
|
|
@ -25,12 +27,15 @@ class QuickSalesPaymentDock extends StatefulWidget {
|
||||||
final List<InvoiceTransaction> payments;
|
final List<InvoiceTransaction> payments;
|
||||||
final ValueChanged<List<InvoiceTransaction>> onChanged;
|
final ValueChanged<List<InvoiceTransaction>> onChanged;
|
||||||
final num invoiceTotal;
|
final num invoiceTotal;
|
||||||
|
final QuickSalesChangeResolution? changeResolution;
|
||||||
|
final Future<QuickSalesChangeResolution?> Function() onConfigureChange;
|
||||||
final String? defaultCashRegisterId;
|
final String? defaultCashRegisterId;
|
||||||
final String currencyUnit;
|
final String currencyUnit;
|
||||||
final int decimalPlaces;
|
final int decimalPlaces;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onSaveAndPrint;
|
final VoidCallback onSaveAndPrint;
|
||||||
|
final QuickSalesService? service;
|
||||||
|
|
||||||
const QuickSalesPaymentDock({
|
const QuickSalesPaymentDock({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -39,12 +44,15 @@ class QuickSalesPaymentDock extends StatefulWidget {
|
||||||
required this.payments,
|
required this.payments,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.invoiceTotal,
|
required this.invoiceTotal,
|
||||||
|
required this.changeResolution,
|
||||||
|
required this.onConfigureChange,
|
||||||
required this.defaultCashRegisterId,
|
required this.defaultCashRegisterId,
|
||||||
required this.currencyUnit,
|
required this.currencyUnit,
|
||||||
required this.decimalPlaces,
|
required this.decimalPlaces,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onSaveAndPrint,
|
required this.onSaveAndPrint,
|
||||||
|
this.service,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -57,9 +65,13 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
static const double _minButtonWidth = 300;
|
static const double _minButtonWidth = 300;
|
||||||
static const double _minListWidth = 360;
|
static const double _minListWidth = 360;
|
||||||
static const double _summaryWidth = 384;
|
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;
|
static const int _maxPaymentLines = 50;
|
||||||
|
|
||||||
final QuickSalesService _service = QuickSalesService();
|
late final QuickSalesService _service;
|
||||||
final TextEditingController _amountController = TextEditingController();
|
final TextEditingController _amountController = TextEditingController();
|
||||||
final FocusNode _amountFocus = FocusNode();
|
final FocusNode _amountFocus = FocusNode();
|
||||||
final ScrollController _paymentScroll = ScrollController();
|
final ScrollController _paymentScroll = ScrollController();
|
||||||
|
|
@ -67,6 +79,7 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
List<QuickSalesPaymentButtonModel> _buttons = const [];
|
List<QuickSalesPaymentButtonModel> _buttons = const [];
|
||||||
bool _loadingButtons = true;
|
bool _loadingButtons = true;
|
||||||
double _buttonWidth = _defaultButtonWidth;
|
double _buttonWidth = _defaultButtonWidth;
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
num get _epsilon {
|
num get _epsilon {
|
||||||
if (widget.decimalPlaces <= 0) return 0.5;
|
if (widget.decimalPlaces <= 0) return 0.5;
|
||||||
|
|
@ -88,10 +101,13 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
|
|
||||||
String get _preferenceKey =>
|
String get _preferenceKey =>
|
||||||
'quick_sales_payment_dock_buttons_width_v2_${widget.businessId}_${widget.authStore.currentUserId ?? 0}';
|
'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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_service = widget.service ?? QuickSalesService();
|
||||||
HardwareKeyboard.instance.addHandler(_handleShortcutKeyEvent);
|
HardwareKeyboard.instance.addHandler(_handleShortcutKeyEvent);
|
||||||
_amountFocus.addListener(() {
|
_amountFocus.addListener(() {
|
||||||
if (_amountFocus.hasFocus) {
|
if (_amountFocus.hasFocus) {
|
||||||
|
|
@ -104,6 +120,7 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
_syncAmount();
|
_syncAmount();
|
||||||
unawaited(_loadButtons());
|
unawaited(_loadButtons());
|
||||||
unawaited(_loadWidth());
|
unawaited(_loadWidth());
|
||||||
|
unawaited(_loadExpanded());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -195,9 +212,25 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
await prefs.setDouble(_preferenceKey, _buttonWidth);
|
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) =>
|
String _format(num value) =>
|
||||||
formatWithThousands(value, decimalPlaces: widget.decimalPlaces);
|
formatWithThousands(value, decimalPlaces: widget.decimalPlaces);
|
||||||
|
|
||||||
|
String _formatPaymentDate(DateTime value) =>
|
||||||
|
DateFormatters.formatServerDateTime(value.toIso8601String());
|
||||||
|
|
||||||
void _addPayment(QuickSalesPaymentButtonModel button) {
|
void _addPayment(QuickSalesPaymentButtonModel button) {
|
||||||
if (!widget.enabled) return;
|
if (!widget.enabled) return;
|
||||||
if (widget.payments.length >= _maxPaymentLines) {
|
if (widget.payments.length >= _maxPaymentLines) {
|
||||||
|
|
@ -280,6 +313,15 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
_ => Icons.account_balance_wallet_outlined,
|
_ => 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) {
|
IconData _buttonIcon(QuickSalesPaymentTargetType type) => switch (type) {
|
||||||
QuickSalesPaymentTargetType.cashRegister => Icons.payments_outlined,
|
QuickSalesPaymentTargetType.cashRegister => Icons.payments_outlined,
|
||||||
QuickSalesPaymentTargetType.bank => Icons.account_balance,
|
QuickSalesPaymentTargetType.bank => Icons.account_balance,
|
||||||
|
|
@ -289,13 +331,14 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth < 1050) {
|
||||||
return Material(
|
return Material(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
color: cs.surface,
|
color: cs.surface,
|
||||||
child: LayoutBuilder(
|
child: SizedBox(height: 260, child: _buildCompact(cs)),
|
||||||
builder: (context, constraints) {
|
);
|
||||||
if (constraints.maxWidth < 1050) {
|
|
||||||
return SizedBox(height: 260, child: _buildCompact(cs));
|
|
||||||
}
|
}
|
||||||
final maxButtons =
|
final maxButtons =
|
||||||
(constraints.maxWidth - _summaryWidth - _minListWidth - 12)
|
(constraints.maxWidth - _summaryWidth - _minListWidth - 12)
|
||||||
|
|
@ -305,14 +348,53 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
.clamp(_minButtonWidth, maxButtons)
|
.clamp(_minButtonWidth, maxButtons)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 220,
|
height: _dockHeight,
|
||||||
child: DecoratedBox(
|
child: Stack(
|
||||||
decoration: BoxDecoration(
|
clipBehavior: Clip.none,
|
||||||
border: Border(top: BorderSide(color: cs.outlineVariant)),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
child: Row(
|
||||||
textDirection: TextDirection.rtl,
|
textDirection: TextDirection.rtl,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: buttonWidth,
|
width: buttonWidth,
|
||||||
|
|
@ -324,8 +406,12 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
_ResizeHandle(
|
_ResizeHandle(
|
||||||
onReset: () {
|
onReset: () {
|
||||||
setState(
|
setState(
|
||||||
() => _buttonWidth = _defaultButtonWidth
|
() =>
|
||||||
.clamp(_minButtonWidth, maxButtons)
|
_buttonWidth = _defaultButtonWidth
|
||||||
|
.clamp(
|
||||||
|
_minButtonWidth,
|
||||||
|
maxButtons,
|
||||||
|
)
|
||||||
.toDouble(),
|
.toDouble(),
|
||||||
);
|
);
|
||||||
unawaited(_saveWidth());
|
unawaited(_saveWidth());
|
||||||
|
|
@ -333,7 +419,10 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
onDrag: (dx) {
|
onDrag: (dx) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_buttonWidth = (_buttonWidth - dx)
|
_buttonWidth = (_buttonWidth - dx)
|
||||||
.clamp(_minButtonWidth, maxButtons)
|
.clamp(
|
||||||
|
_minButtonWidth,
|
||||||
|
maxButtons,
|
||||||
|
)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -341,30 +430,72 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
onNudge: (dx) {
|
onNudge: (dx) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_buttonWidth = (_buttonWidth + dx)
|
_buttonWidth = (_buttonWidth + dx)
|
||||||
.clamp(_minButtonWidth, maxButtons)
|
.clamp(
|
||||||
|
_minButtonWidth,
|
||||||
|
maxButtons,
|
||||||
|
)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
});
|
});
|
||||||
unawaited(_saveWidth());
|
unawaited(_saveWidth());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(child: _buildPaymentList(cs)),
|
Expanded(child: _buildPaymentList(cs)),
|
||||||
VerticalDivider(
|
],
|
||||||
width: 1,
|
|
||||||
thickness: 1,
|
|
||||||
color: cs.outlineVariant,
|
|
||||||
),
|
),
|
||||||
SizedBox(
|
)
|
||||||
width: _summaryWidth,
|
else
|
||||||
child: ColoredBox(
|
Positioned(
|
||||||
color: cs.surfaceContainerLow,
|
top: 0,
|
||||||
child: _buildSummary(cs),
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: _collapsedHeight,
|
||||||
|
child: _buildCollapsedBar(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),
|
Divider(height: 1, color: cs.outlineVariant),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final payment = widget.payments[index];
|
final payment = widget.payments[index];
|
||||||
|
final paymentColor = _paymentColor(payment.type, cs);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 58,
|
height: 58,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
_paymentTitle(payment),
|
_paymentTitle(payment),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
SizedBox(
|
||||||
|
|
@ -506,12 +678,19 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
_updatePaymentAmount(payment, value),
|
_updatePaymentAmount(payment, value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'حذف پرداخت',
|
tooltip: 'حذف پرداخت',
|
||||||
onPressed: widget.enabled
|
onPressed: widget.enabled
|
||||||
? () => _removePayment(payment.id)
|
? () => _removePayment(payment.id)
|
||||||
: null,
|
: 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,6 +729,11 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
if (_change > 0)
|
if (_change > 0)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 6),
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.enabled
|
||||||
|
? () => unawaited(widget.onConfigureChange())
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
|
|
@ -559,15 +743,33 @@ class _QuickSalesPaymentDockState extends State<QuickSalesPaymentDock> {
|
||||||
color: cs.errorContainer,
|
color: cs.errorContainer,
|
||||||
borderRadius: BorderRadius.circular(7),
|
borderRadius: BorderRadius.circular(7),
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'مبلغ قابل برگشت: ${_format(_change)} ${widget.currencyUnit}',
|
widget.changeResolution == null
|
||||||
|
? 'تعیین تکلیف ${_format(_change)} ${widget.currencyUnit}'
|
||||||
|
: 'برگشت ${_format(widget.changeResolution!.returnedAmount)} • بستانکاری ${_format(widget.changeResolution!.creditAmount)}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onErrorContainer,
|
color: cs.onErrorContainer,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Icon(
|
||||||
|
widget.changeResolution == null
|
||||||
|
? Icons.rule_folder_outlined
|
||||||
|
: Icons.edit_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: cs.onErrorContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -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 {
|
class _ResizeHandle extends StatelessWidget {
|
||||||
final ValueChanged<double> onDrag;
|
final ValueChanged<double> onDrag;
|
||||||
final ValueChanged<double> onNudge;
|
final ValueChanged<double> onNudge;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hesabix_ui/models/customer_model.dart';
|
import 'package:hesabix_ui/models/customer_model.dart';
|
||||||
import 'package:hesabix_ui/models/invoice_line_item.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_parked_sale.dart';
|
||||||
|
import 'package:hesabix_ui/models/quick_sales_change_resolution.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('parked sale round-trips cart, customer and totals', () {
|
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', () {
|
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(
|
final sale = QuickSalesParkedSale(
|
||||||
id: 'a',
|
id: 'a',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
|
@ -72,4 +76,42 @@ void main() {
|
||||||
expect(parsed?.activeId, 'a');
|
expect(parsed?.activeId, 'a');
|
||||||
expect(parsed?.sales, hasLength(1));
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
hesabixUI/hesabix_ui/test/quick_sales_payment_dock_test.dart
Normal file
113
hesabixUI/hesabix_ui/test/quick_sales_payment_dock_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue