codex/quick-sales-payment-dock #15

Merged
morrning merged 3 commits from Seyyed/Seyyed_arc:codex/quick-sales-payment-dock into master 2026-10-14 01:39:04 +03:30
18 changed files with 2889 additions and 310 deletions

View file

@ -0,0 +1,240 @@
# مرجع قابلیت‌های یک سامانه فروش سریع برای توسعه تدریجی آرک
## هدف سند
این سند دانش جمع‌آوری‌شده از بررسی رابط یک سامانه فروش سریع و توضیحات تکمیلی کاربر را نگهداری می‌کند تا در مراحل بعد بتوان قابلیت‌ها را جداگانه تحلیل، طراحی و به فروش سریع آرک اضافه کرد.
این سند مشخصات رسمی نرم‌افزار مرجع نیست. موارد بخش «رفتارهای تأییدشده» مستقیماً توسط کاربرِ آشنا با عملکرد آن تأیید شده‌اند. موارد نامشخص در بخش «پرسش‌های باز» باقی می‌مانند و نباید هنگام طراحی آرک به‌عنوان واقعیت قطعی فرض شوند.
### منابع تصویری گفتگو
- تصویر اول گفتگو: نمای کلی صفحه فروش سریع
- تصویر دوم گفتگو: نمونه تقسیم پرداخت میان دو کارت‌خوان و وجه نقد
- تاریخ ثبت این دانش: ۱۴۰۵/۰۷/۲۱ (۲۰۲۶/۱۰/۱۳)
فایل‌های تصویر در زمان تهیه این سند پیوست گفتگو بوده‌اند و داخل مخزن کپی نشده‌اند؛ شرح رفتارهای تأییدشده در ادامه مرجع پایدار پروژه است.
## مدل کلی صفحه
نرم‌افزار مرجع یک صندوق فروش تک‌صفحه‌ای و مناسب بارکدخوان، صفحه‌کلید و نمایشگر لمسی است. اجزای اصلی آن عبارت‌اند از:
1. زمینه مشتری و فاکتور فعال
2. فیلد ورود کالا با بارکد، کد یا نام
3. جدول اقلام فاکتور با ویرایش سریع مقدار
4. کالاهای دم‌دستی قابل تعریف
5. خلاصه مبلغ، پرداخت‌شده، مانده و مبلغ برگشتی
6. ردیف‌های مستقل پرداخت
7. دکمه‌های عملیاتی قابل تعریف با کلید میانبر و زنجیره عملیات
## رفتارهای تأییدشده
### ۱. چند فروش هم‌زمان و پارک سفارش
- کاربر می‌تواند چند فروش را هم‌زمان باز نگه دارد و میان آن‌ها جابه‌جا شود.
- فروش‌های ناتمام به‌صورت سفارش یا سبد پارک‌شده نگهداری می‌شوند.
- این قابلیت در فروش سریع آرک نیز در حال حاضر وجود دارد.
### ۲. حرکت میان فاکتورهای ثبت‌شده
- کنترل «جدید» و جهت‌های کنار آن برای حرکت میان فاکتورهاست.
- اگر فاکتور هنوز ثبت نشده باشد، به‌جای شماره فاکتور عبارت «جدید» نمایش داده می‌شود.
- حرکت فقط محدود به فروش‌های پارک‌شده نیست و فاکتورهای ثبت‌شده را نیز شامل می‌شود.
- کاربر می‌تواند فاکتور ثبت‌شده را ویرایش کند یا برای آن برگشت کلی بزند.
### ۳. ورود و جست‌وجوی کالا
- ورود با بارکد، کد کالا یا نام کالا انجام می‌شود.
- بارکد یا کد یکتا مستقیماً کالا را وارد جدول فاکتور می‌کند.
- جست‌وجوی نام، نتایج را در منوی شناور نشان می‌دهد.
- حرکت در نتایج و انتخاب با کلیدهای جهت، Enter یا موس انجام می‌شود.
- منطق فعلی جست‌وجو و انتخاب کالای فروش سریع آرک رفتار مشابهی دارد.
### ۴. کالاهای دم‌دستی
- پنل کنار صفحه شامل کالاهایی است که کاربر برای افزودن سریع به فاکتور تعریف می‌کند.
- این قابلیت برای کالاهای پرتکرار و کالاهای کوچکِ بدون برچسب بارکد کاربرد دارد.
- طبق تنظیمات، چیدمان می‌تواند برای هر کاربر جداگانه یا برای همه کاربران تعریف شود.
- انتخاب یک کالای دم‌دستی، آن را بدون نیاز به اسکن یا جست‌وجو وارد فاکتور می‌کند.
### ۵. ثبت سریع مشتری
- فیلدهای نام، موبایل و نشانی/موقعیت بالای صفحه برای ثبت سریع مشتری هستند.
- روش ثبت سریع مشتری در آرک متفاوت است و در این گفتگو سریع‌تر و مناسب‌تر ارزیابی شده است.
### ۶. تقسیم مبلغ بین چند روش پرداخت
پرداخت‌ها پیش از ثبت نهایی فاکتور به‌صورت ردیف‌های مستقل به فاکتور اضافه می‌شوند. مشتری می‌تواند مبلغ را با چند کارت، چند کارت‌خوان و وجه نقد پرداخت کند.
در نمونه مشاهده‌شده:
| عنوان | مبلغ |
|---|---:|
| مبلغ نهایی فاکتور | ۳۷٬۷۰۰٬۰۰۰ |
| پرداخت با کارت‌خوان سامان | ۵٬۰۰۰٬۰۰۰ |
| پرداخت با کارت‌خوان رسالت | ۲٬۶۰۰٬۰۰۰ |
| پرداخت نقدی | ۲۶٬۴۰۰٬۰۰۰ |
| مجموع پرداخت‌ها | ۳۴٬۰۰۰٬۰۰۰ |
| مانده | ۳٬۷۰۰٬۰۰۰ |
- فیلد «مبلغ» در شروع، مبلغ نهایی فاکتور پس از تخفیف و مالیات را نشان می‌دهد.
- کاربر می‌تواند مبلغ موردنظر برای پرداخت بعدی را در این فیلد وارد کند.
- با انتخاب یک روش پرداخت، ردیف پرداخت به فهرست اضافه می‌شود.
- پس از هر پرداخت، فیلد مبلغ خودکار روی مانده جدید قرار می‌گیرد.
- هر ردیف پرداخت قابل حذف است.
- اگر فاکتور با مانده ثبت شود، مانده به بدهی مشتری تبدیل می‌شود.
- ثبت بدهی حتی برای «مشتری عمومی» مجاز است.
فرمول مفهومی مانده:
```text
مانده = مبلغ نهایی فاکتور - مجموع پرداخت‌های منظورشده
```
### ۷. اضافه‌پرداخت و برگرداندن وجه
- ورود پرداخت بیشتر از مانده مجاز است.
- سامانه مبلغ اضافه را به‌عنوان مبلغ برگشتی محاسبه می‌کند.
- در این وضعیت دکمه «برگرداندن» فعال می‌شود.
- اجرای «برگرداندن»، پرداخت نقدی به مشتری را از صندوق نقدی پیش‌فرض ثبت می‌کند.
نمونه حسابداری مفهومی برای فاکتور ۱۰۰ و دریافت کارتی ۱۲۰:
```text
دریافت کارت: ۱۲۰
مبلغ فاکتور: ۱۰۰
پرداخت نقدی از صندوق پیش‌فرض به مشتری: ۲۰
```
جزئیات حساب‌های بدهکار/بستانکار باید هنگام طراحی در آرک با معماری اسناد دریافت/پرداخت و صندوق تطبیق داده شود.
### ۸. صندوق‌ها و کارت‌خوان‌های قابل تعریف
- نام‌هایی مانند «سامان» و «رسالت» قابلیت ثابت نرم‌افزار نیستند؛ کاربر آن‌ها را تعریف می‌کند.
- هر دکمه پرداخت به یک صندوق یا کارت‌خوان تعریف‌شده متصل می‌شود.
- عملیات می‌تواند فقط پرداخت را در صندوق مربوط ثبت کند و الزاماً مبلغی به دستگاه کارت‌خوان نفرستد.
- اگر اتصال کارت‌خوان تنظیم شده باشد، مبلغ می‌تواند به‌صورت خودکار برای دستگاه ارسال شود.
- مشتری یا فروشنده می‌تواند برای یک فاکتور از چند کارت یا چند کارت‌خوان استفاده کند.
### ۹. شکست تراکنش کارت‌خوان
- در صورت بی‌پاسخ‌ماندن کارت‌خوان یا ناموفق‌بودن تراکنش، کاربر می‌تواند کارت‌خوان دیگری انتخاب کند.
- نرم‌افزار می‌تواند از کاربر بپرسد تراکنش ناموفق بماند یا با وجود شکست ارتباط، پرداخت‌شده ثبت شود.
- در نرم‌افزار مرجع، اگر کاربر تراکنش ناموفق را پرداخت‌شده اعلام کند، ردپای مشخصی از این تصمیم نگهداری نمی‌شود.
این رفتار نباید بدون ردپای حسابرسی در آرک تکرار شود. اگر چنین اختیاری در آرک طراحی شود، حداقل کاربر، زمان، دستگاه، پاسخ دریافتی، علت و تأیید دستی باید ثبت شوند.
### ۱۰. دکمه‌های عملیاتی قابل تعریف
- دکمه‌های پایین صفحه مانند «سامان»، «رسالت»، «سامان چاپ»، «رسالت چاپ» و «ذخیره» توسط کاربر تعریف شده‌اند.
- کاربر می‌تواند عنوان و کلید میانبر دکمه را تعیین کند.
- کاربر چند عملیات آماده را به‌ترتیب داخل یک فهرست قرار می‌دهد.
- عملیات نمونه شامل پرداخت، ذخیره، چاپ و بستن فاکتور است.
- ترتیب عملیات باید از نظر منطقی معتبر باشد.
نمونه زنجیره معتبر:
```text
ثبت پرداخت در صندوق سامان
→ ذخیره فاکتور
→ چاپ فاکتور
→ بستن فاکتور
→ نمایش فاکتور خالی جدید
```
چاپ اختیاری است و زنجیره زیر نیز معتبر است:
```text
ثبت پرداخت
→ ذخیره فاکتور
→ بستن فاکتور
```
نمونه ترتیب نامعتبر:
```text
بستن فاکتور
→ ذخیره یا چاپ فاکتور بسته‌شده
```
ویرایشگر دکمه باید وابستگی و ترتیب عملیات را هنگام تعریف کنترل کند، نه اینکه خطا فقط زمان فروش رخ دهد.
### ۱۱. سیاست پرداخت‌های قبلی در دکمه زنجیره‌ای
ممکن است پیش از اجرای دکمه زنجیره‌ای، پرداخت‌هایی دستی به فاکتور اضافه شده باشند. عملیات قابل تعریف می‌تواند از کاربر درباره پرداخت‌های قبلی سؤال کند:
- **حذف پرداخت‌های قبلی:** همه ردیف‌های قبلی حذف و کل مبلغ فاکتور با روش پرداخت دکمه جدید منظور می‌شود.
- **حفظ پرداخت‌های قبلی:** ردیف‌های قبلی باقی می‌مانند و اگر مانده‌ای وجود داشته باشد، فقط همان مانده با روش پرداخت دکمه تسویه می‌شود.
### ۱۲. کنترل کسری موجودی
- رفتار فروش با کسری موجودی در نرم‌افزار مرجع به تنظیمات وابسته است.
- فروش سریع آرک اکنون گردش‌کار مشخص انصراف، پارک فروش، اصلاح موجودی و ثبت یک‌باره با موجودی منفی دارد.
- ثبت منفی در آرک پرونده مغایرت می‌سازد و از مسیر «انبار ← مغایرت‌های موجودی فروش سریع» قابل رسیدگی است.
- جزئیات این پیاده‌سازی در `docs/QUICK_SALES_STOCK_SHORTAGE_WORKFLOW.md` ثبت شده است.
## قابلیت‌های پیشنهادی برای بررسی مرحله‌ای در آرک
این فهرست به‌معنای تأیید پیاده‌سازی نیست. برای هر مورد باید ابتدا وضعیت فعلی آرک بررسی و سپس نیازمندی مستقل تأیید شود.
| قابلیت مرجع | وضعیت شناخته‌شده در آرک | اقدام بعدی پیشنهادی |
|---|---|---|
| چند فروش و پارک سفارش | موجود | نگهداری و تکمیل تست‌های پایداری |
| جست‌وجوی بارکد/کد/نام و انتخاب شناور | موجود | نگهداری رفتار فعلی |
| ثبت سریع مشتری | موجود با منطق متفاوت | نگهداری رفتار فعلی و ارزیابی جداگانه |
| کسری موجودی و پرونده مغایرت | موجود | نگهداری مستند و تست‌شده |
| کالاهای دم‌دستی قابل تعریف | نیازمند بررسی کد فعلی | تحلیل دامنه شخصی/عمومی و طراحی مدیریت چیدمان |
| تقسیم پرداخت و نمایش مانده | نیازمند بررسی دقیق رفتار فعلی | تحلیل مدل پرداخت پیش از تغییر UI |
| اضافه‌پرداخت و برگشت نقدی | نیازمند بررسی | طراحی سند خروج از صندوق و کنترل صندوق پیش‌فرض |
| صندوق/کارت‌خوان قابل اتصال به دکمه | نیازمند بررسی | تفکیک ثبت حسابداری از ارتباط سخت‌افزاری |
| دکمه زنجیره‌ای قابل تعریف | نیازمند طراحی | تعریف عملیات، ترتیب، اعتبارسنجی و مجوزها |
| کلید میانبر قابل تعریف | نیازمند بررسی | طراحی جلوگیری از تداخل میانبرها |
| حرکت میان فاکتورهای ثبت‌شده | نیازمند بررسی | تعیین دامنه مشاهده، ویرایش و برگشت |
| ویرایش یا برگشت کلی فاکتور ثبت‌شده | نیازمند بررسی معماری | حفظ ردپای حسابرسی و معکوس‌سازی اسناد وابسته |
## اصول پیشنهادی برای طراحی آرک
هنگام تبدیل هر قابلیت به طراحی اجرایی، این اصول حفظ شوند:
1. ردیف پرداخت از نتیجه تراکنش سخت‌افزاری تفکیک شود؛ «ثبت در صندوق» و «ارسال به کارت‌خوان» دو عملیات جدا هستند.
2. هر عملیات زنجیره‌ای شناسه درخواست و رفتار idempotent داشته باشد تا تکرار کلیک یا قطع ارتباط باعث ثبت دوباره پرداخت یا فاکتور نشود.
3. وابستگی عملیات در زمان ساخت دکمه اعتبارسنجی شود؛ برای نمونه چاپ و بستن به ذخیره موفق وابسته‌اند.
4. شکست هر مرحله باید ادامه یا توقف مراحل بعدی را طبق سیاست صریح تعیین کند.
5. تأیید دستی پرداخت ناموفق باید ردپای حسابرسی کامل داشته باشد.
6. تغییر فاکتور ثبت‌شده نباید اسناد حسابداری، انبار و پرداخت را بدون سابقه بازنویسی کند.
7. بدهی مشتری عمومی باید در گزارش‌ها قابل شناسایی باشد تا مانده‌های فاقد مشتری واقعی پنهان نشوند.
8. برگشت وجه نقد باید صندوق، کاربر، علت و ارتباط با پرداخت اضافه را ثبت کند.
9. قابلیت‌های شخصی و عمومی مانند کالاهای دم‌دستی باید مجوز، مالک و اولویت اعمال مشخص داشته باشند.
10. هر قابلیت در یک مرحله مستقل همراه با سناریوهای پذیرش، مستندات و مهاجرت لازم اجرا شود.
## پرسش‌های باز
### ویرایش و برگشت فاکتور ثبت‌شده
هنوز مشخص نیست نرم‌افزار مرجع هنگام ویرایش یا برگشت کلی:
- اسناد پرداخت، حسابداری و انبار قبلی را معکوس و اسناد جدید ایجاد می‌کند؛
- یا همان اسناد قبلی را مستقیماً تغییر می‌دهد.
برای طراحی آرک، روش مبتنی بر ردپای تغییر و اسناد معکوس باید مبنای بررسی باشد.
### جزئیات کارت‌خوان واقعی
تصویر بعدی کاربر قرار است رفتار دو کارت‌خوان یا دو حالت کارت‌کشیدن واقعی را نشان دهد. پس از دریافت آن باید موارد زیر به این سند افزوده شود:
- وضعیت‌های شروع، انتظار، موفق، ناموفق و بی‌پاسخ
- شماره مرجع و شماره کارت ماسک‌شده
- رفتار تکرار، تغییر کارت‌خوان و لغو
- ارتباط نتیجه دستگاه با ردیف پرداخت
- رفتار دکمه زنجیره‌ای در شکست هر مرحله
## روش ادامه کار
برای هر قابلیت جدید:
1. رفتار مرجع با مثال و تصویر ثبت شود.
2. وضعیت فعلی آرک در فرانت، API، مدل داده و حسابداری بررسی شود.
3. تفاوت‌ها و تصمیم محصول مشخص شوند.
4. طرح اجرایی مستقل نوشته و تأیید شود.
5. تغییر در شاخه جدا یا شاخه مورد توافق اجرا شود.
6. سناریوهای پذیرش و مستندات همراه کد اضافه شوند.
7. پس از تست کاربر، تغییرات commit و push شوند.

View file

@ -0,0 +1,89 @@
# نوار پرداخت سریع فروش
## هدف
این قابلیت ورود پرداخت‌های یک فاکتور فروش سریع را به نوار پایین صفحه منتقل می‌کند. کاربر می‌تواند مبلغ فاکتور را بین چند صندوق، حساب بانکی یا شخص تقسیم کند و هر پرداخت به‌صورت یک ردیف مستقل ثبت می‌شود.
## رفتار رابط کاربری
- فیلد «مبلغ پرداخت» در شروع، مانده فعلی فاکتور را نشان می‌دهد و قابل ویرایش است.
- فشردن یک دکمه پرداخت، مبلغ داخل فیلد را به مقصد همان دکمه اضافه می‌کند.
- پس از افزودن پرداخت، فیلد مبلغ با مانده جدید به‌روزرسانی می‌شود.
- پرداخت‌های ثبت‌شده در یک فهرست مستقل و اسکرول‌دار نمایش داده می‌شوند. مبلغ هر ردیف قابل ویرایش و خود ردیف قابل حذف است.
- کاربر می‌تواند مرز بین فضای دکمه‌ها و فهرست پرداخت‌ها را با ماوس جابه‌جا کند. عرض انتخاب‌شده برای همان کاربر و کسب‌وکار در مرورگر ذخیره می‌شود. دوبار کلیک روی مرز، عرض پیش‌فرض را برمی‌گرداند و کلیدهای جهت نیز مرز را حرکت می‌دهند.
- جمع پرداخت‌ها اگر از مبلغ فاکتور بیشتر شود، مبلغ قابل برگشت با رنگ خطا نمایش داده می‌شود. خود فیلد مبلغ در این حالت صفر می‌ماند.
- ثبت فاکتور بدون هیچ پرداختی، پس از تأیید کاربر، کل مبلغ را بدهی مشتری ثبت می‌کند.
## دکمه‌های پرداخت
هر دکمه یکی از این مقصدها را دارد:
1. صندوق
2. حساب بانکی
3. شخص
در مقصد شخص، عنوان دکمه همیشه نام همان شخص است. این حالت برای زمانی است که وجه فاکتور به حساب شخصی صندوقدار یا شخص تعریف‌شده دیگری واریز شده باشد. در سند دریافت، شخص مقصد بدهکار می‌شود؛ یعنی وجه کسب‌وکار نزد او ثبت می‌شود.
دکمه می‌تواند شخصی یا مشترک باشد. دکمه شخصی فقط برای سازنده دیده می‌شود. ایجاد، ویرایش، حذف و تغییر ترتیب دکمه مشترک به دسترسی تنظیمات کسب‌وکار نیاز دارد.
میانبرهای مجاز شامل `F1` تا `F12` به‌جز `F5` و ترکیب‌هایی مانند `Alt+1` یا `Ctrl+Shift+K` هستند. میانبرهای رزروشده فروش سریع و مرورگر پذیرفته نمی‌شوند و یک میانبر مؤثر نمی‌تواند برای دو دکمه تکرار شود.
اگر هنوز هیچ دکمه‌ای تعریف نشده باشد و در تنظیمات فروش سریع صندوق پیش‌فرض وجود داشته باشد، یک دکمه سیستمی موقت برای همان صندوق نمایش داده می‌شود. با تعریف اولین دکمه، چیدمان تعریف‌شده کاربر جای آن را می‌گیرد.
## مدل داده و مهاجرت
جدول `quick_sales_payment_buttons` توسط migration زیر ساخته می‌شود:
`20261013_000001_quick_sales_payment_buttons`
اطلاعات اصلی جدول شامل کسب‌وکار، دامنه شخصی/مشترک، مالک، نوع و شناسه مقصد، عنوان، میانبر، ترتیب و وضعیت فعال است. constraintهای دیتابیس تضمین می‌کنند هر ردیف فقط یک مقصد معتبر داشته باشد و دکمه شخصی حتماً مالک داشته باشد.
این جدول همراه کد در Git منتقل می‌شود. پس از دریافت شاخه، اجرای migrationهای Alembic جدول را در محیط مقصد ایجاد می‌کند؛ انتقال دستی SQL لازم نیست.
## API
- `GET /api/v1/businesses/{business_id}/quick-sales/payment-buttons`
- `POST /api/v1/businesses/{business_id}/quick-sales/payment-buttons`
- `PUT /api/v1/businesses/{business_id}/quick-sales/payment-buttons/reorder`
- `PUT /api/v1/businesses/{business_id}/quick-sales/payment-buttons/{button_id}`
- `DELETE /api/v1/businesses/{business_id}/quick-sales/payment-buttons/{button_id}`
همه مقصدها در بک‌اند از نظر وجود، فعال بودن و تعلق به کسب‌وکار کنترل می‌شوند. شخص مقصد نیز نمی‌تواند همان مشتری فاکتور باشد.
## ثبت حسابداری پرداخت اضافه
اگر جمع دریافت‌ها از خالص فاکتور بیشتر باشد، ثبت فقط پس از تأیید صریح برگشت وجه انجام می‌شود. صندوق نقدی انتخاب‌شده باید فعال، متعلق به همان کسب‌وکار و هم‌ارز فاکتور باشد.
در یک تراکنش دیتابیس این عملیات انجام می‌شود:
1. فاکتور فروش ثبت می‌شود.
2. برای هر ردیف پرداخت، سند دریافت مستقل ساخته می‌شود.
3. برای مبلغ اضافه، یک سند پرداخت از صندوق به مشتری ساخته می‌شود.
4. شناسه سند برگشت وجه جدا از اسناد دریافت در `extra_info.links.quick_sale_change_payment_document_id` ذخیره می‌شود.
مانده فاکتور از دریافت ناخالص منهای وجه برگشتی محاسبه می‌شود. در نتیجه فاکتوری که وجه اضافه آن به مشتری برگردانده شده، تسویه‌شده باقی می‌ماند. هنگام ویرایش، تبدیل به پیش‌فاکتور یا حذف فاکتور، سند برگشت وجه نیز در همان تراکنش به‌روزرسانی یا حذف می‌شود.
## نکات سازگاری
- پرداخت خودکار کل مبلغ با صندوق پیش‌فرض حذف شده است؛ فاکتور جدید بدون اقدام کاربر، ردیف پرداخت ندارد.
- حالت موبایل همچنان از فرم فشرده قبلی استفاده می‌کند، ولی پرداخت بیشتر از مبلغ فاکتور و سقف ۵۰ ردیف را پشتیبانی می‌کند.
- قابلیت اشتراک‌گذاری مانده فاکتور در ستون اطلاعات سند دسکتاپ حفظ شده است.
- اگر ساخت خودکار سند دریافت در تنظیمات غیرفعال باشد، نوار دکمه‌های پرداخت نمایش داده نمی‌شود و رفتار قبلی اشتراک‌گذاری و ثبت باقی می‌ماند.
## سناریوهای پذیرش
1. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون نقدی و ۶ میلیون بانکی ثبت می‌شود و مانده به صفر می‌رسد.
2. مبلغ فاکتور ۱۰ میلیون است؛ ۴ میلیون بانکی ثبت می‌شود و فیلد مبلغ ۶ میلیون را نشان می‌دهد.
3. مبلغ فاکتور ۱۰ میلیون است؛ ۱۱ میلیون دریافت می‌شود، مبلغ برگشتی ۱ میلیون قرمز نمایش داده می‌شود و پس از تأیید، سند پرداخت ۱ میلیون از صندوق ساخته می‌شود.
4. دو پرداخت به دو حساب بانکی متفاوت به‌صورت دو ردیف مستقل باقی می‌مانند.
5. پرداخت به شخص، آن شخص را بدهکار و مشتری فاکتور را به همان مبلغ بستانکار می‌کند.
6. حذف یا ویرایش فاکتور دارای وجه برگشتی، اسناد دریافت و برگشت مرتبط را نیز هماهنگ می‌کند.
7. کاربر عرض بخش دکمه‌ها را تغییر می‌دهد و پس از بازکردن دوباره صفحه همان عرض بازیابی می‌شود.
8. میانبر تعریف‌شده، مبلغ جاری فیلد را به مقصد همان دکمه اضافه می‌کند.
## بررسی‌های خودکار
- تست منطق پرداخت Flutter: مستقل ماندن ردیف‌ها و مجاز بودن اضافه‌پرداخت.
- تست سرویس بک‌اند: نرمال‌سازی و محدودیت میانبرها.
- تست محاسبه مبلغ برگشتی با پرداخت معمولی و بین‌ارزی.
- بررسی head مهاجرت Alembic و تحلیل استاتیک فایل‌های جدید Flutter.

View file

@ -1,16 +1,16 @@
from __future__ import annotations
import json
from datetime import date, datetime
import logging
from typing import Dict, Any
from datetime import date, datetime
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from fastapi import APIRouter, Body, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.person import PersonType
from app.core.auth_dependency import get_current_user, AuthContext
from adapters.db.session import get_db
from app.core.auth_dependency import AuthContext, get_current_user
from app.core.permissions import (
has_business_permission_for_business,
require_business_access_dep,
@ -18,9 +18,14 @@ from app.core.permissions import (
)
from app.core.responses import ApiError, success_response
from app.services.quick_sales_service import (
get_quick_sales_settings,
update_quick_sales_settings,
create_quick_sales_payment_button,
delete_quick_sales_payment_button,
get_or_create_anonymous_customer,
get_quick_sales_settings,
list_quick_sales_payment_buttons,
reorder_quick_sales_payment_buttons,
update_quick_sales_payment_button,
update_quick_sales_settings,
)
logger = logging.getLogger(__name__)
@ -110,6 +115,96 @@ def get_anonymous_customer_endpoint(
raise
@router.get("/payment-buttons", summary="دکمه‌های پرداخت فروش سریع")
def list_payment_buttons_endpoint(
request: Request,
business_id: int,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = list_quick_sales_payment_buttons(db, business_id, ctx.get_user_id())
return success_response(data, request)
@router.post("/payment-buttons", summary="ایجاد دکمه پرداخت فروش سریع")
def create_payment_button_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = create_quick_sales_payment_button(
db,
business_id=business_id,
user_id=ctx.get_user_id(),
payload=payload,
can_manage_business=_can(ctx, db, business_id, "settings", "business"),
)
return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTON_CREATED")
@router.put("/payment-buttons/reorder", summary="تغییر ترتیب دکمه‌های پرداخت فروش سریع")
def reorder_payment_buttons_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = reorder_quick_sales_payment_buttons(
db,
business_id=business_id,
user_id=ctx.get_user_id(),
button_ids=list(payload.get("button_ids") or []),
can_manage_business=_can(ctx, db, business_id, "settings", "business"),
)
return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTONS_REORDERED")
@router.put("/payment-buttons/{button_id}", summary="ویرایش دکمه پرداخت فروش سریع")
def update_payment_button_endpoint(
request: Request,
business_id: int,
button_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = update_quick_sales_payment_button(
db,
business_id=business_id,
button_id=button_id,
user_id=ctx.get_user_id(),
payload=payload,
can_manage_business=_can(ctx, db, business_id, "settings", "business"),
)
return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTON_UPDATED")
@router.delete("/payment-buttons/{button_id}", summary="حذف دکمه پرداخت فروش سریع")
def delete_payment_button_endpoint(
request: Request,
business_id: int,
button_id: int,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
delete_quick_sales_payment_button(
db,
business_id=business_id,
button_id=button_id,
user_id=ctx.get_user_id(),
can_manage_business=_can(ctx, db, business_id, "settings", "business"),
)
return success_response({"id": button_id}, request, message="QUICK_SALES_PAYMENT_BUTTON_DELETED")
@router.get("/stock-discrepancies", summary="فهرست مغایرت‌های موجودی فروش سریع")
def list_stock_discrepancies_endpoint(
request: Request,

View file

@ -89,6 +89,7 @@ from .warehouse_document import WarehouseDocument # noqa: F401
from .warehouse_document_line import WarehouseDocumentLine # noqa: F401
from .goods_expense_income import GoodsExpenseIncomeDocument, GoodsExpenseIncomeLine # noqa: F401
from .inventory_discrepancy import InventoryDiscrepancyCase, InventoryDiscrepancyLine # noqa: F401
from .quick_sales_payment_button import QuickSalesPaymentButton # noqa: F401
from .hscript_report import HScriptReport, HScriptReportVersion, HScriptReportRun, HScriptReportSchedule # noqa: F401
from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401
from .ping_pong_score import PingPongScore # noqa: F401

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class QuickSalesPaymentButton(Base):
"""دکمهٔ مقصد پرداخت در فروش سریع، مشترک یا شخصی."""
__tablename__ = "quick_sales_payment_buttons"
__table_args__ = (
CheckConstraint(
"scope IN ('business', 'user')",
name="ck_qspb_scope",
),
CheckConstraint(
"(scope = 'business' AND owner_user_id IS NULL) OR "
"(scope = 'user' AND owner_user_id IS NOT NULL)",
name="ck_qspb_scope_owner",
),
CheckConstraint(
"(target_type = 'cash_register' AND cash_register_id IS NOT NULL "
"AND bank_account_id IS NULL AND person_id IS NULL) OR "
"(target_type = 'bank' AND bank_account_id IS NOT NULL "
"AND cash_register_id IS NULL AND person_id IS NULL) OR "
"(target_type = 'person' AND person_id IS NOT NULL "
"AND cash_register_id IS NULL AND bank_account_id IS NULL)",
name="ck_qspb_exact_target",
),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
owner_user_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
target_type: Mapped[str] = mapped_column(String(24), nullable=False)
cash_register_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("cash_registers.id", ondelete="CASCADE"),
nullable=True,
)
bank_account_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("bank_accounts.id", ondelete="CASCADE"),
nullable=True,
)
person_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("persons.id", ondelete="CASCADE"),
nullable=True,
)
label: Mapped[str | None] = mapped_column(String(120), nullable=True)
shortcut: Mapped[str | None] = mapped_column(String(32), nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by_user_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)

View file

@ -184,6 +184,7 @@ ALLOWED_INVOICE_PROFIT_OVERHEAD_TYPES = {"none", "production_overhead", "all_ove
# کلیدهای لینک اسناد جانبی روی extra_info.links فاکتور
INVOICE_LINK_RECEIPT_PAYMENT_IDS = "receipt_payment_document_ids"
INVOICE_LINK_EXPENSE_INCOME_IDS = "expense_income_document_ids"
INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID = "quick_sale_change_payment_document_id"
def _strip_invoice_side_document_links_from_document(document: Document) -> None:
@ -192,6 +193,7 @@ def _strip_invoice_side_document_links_from_document(document: Document) -> None
links = dict(extra.get("links") or {})
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
links.pop(INVOICE_LINK_EXPENSE_INCOME_IDS, None)
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
@ -202,6 +204,7 @@ def _delete_invoice_linked_side_documents(
*,
receipt_payment_ids: List[Any],
expense_income_ids: List[Any],
change_payment_id: Any = None,
) -> None:
"""
حذف اسناد دریافت/پرداخت و هزینه/درآمد پیوندخورده به فاکتور (بدون commit؛ در همان تراکنش caller).
@ -213,6 +216,10 @@ def _delete_invoice_linked_side_documents(
if rid is None:
continue
delete_receipt_payment(db, int(rid), commit=False)
if change_payment_id is not None:
from app.services.receipt_payment_service import delete_receipt_payment
delete_receipt_payment(db, int(change_payment_id), commit=False)
if expense_income_ids:
from app.services.expense_income_service import delete_expense_income
@ -2425,6 +2432,38 @@ def _validate_invoice_payment_item_currency(
)
ttype = (payment_item.get("transaction_type") or payment_item.get("type") or "").strip().lower()
if ttype == "person":
ref_id = payment_item.get("person_id")
if not ref_id:
raise ApiError(
"PAYMENT_PERSON_REQUIRED",
"Payment person is required",
http_status=400,
)
target_person = db.query(Person).filter(Person.id == int(ref_id)).first()
if not target_person:
raise ApiError(
"PAYMENT_ACCOUNT_NOT_FOUND",
"Payment person not found",
http_status=404,
)
if business_id is not None and int(target_person.business_id) != int(business_id):
raise ApiError(
"PAYMENT_ACCOUNT_BUSINESS_MISMATCH",
"Payment person does not belong to this business",
http_status=400,
)
invoice_person_id = _person_id_from_header(
{"extra_info": invoice.extra_info or {}}
) if invoice is not None else None
if invoice_person_id and int(invoice_person_id) == int(ref_id):
raise ApiError(
"PAYMENT_PERSON_SAME_AS_CUSTOMER",
"شخص مقصد پرداخت نمی‌تواند همان مشتری فاکتور باشد",
http_status=400,
)
return None
if ttype not in ("bank", "cash_register", "petty_cash", "check"):
return None
@ -2722,6 +2761,130 @@ def _create_receipt_payment_documents_for_invoice_payments(
return payment_docs
def _quick_sale_change_amount(document: Document, payments: List[Dict[str, Any]]) -> Decimal:
"""مبلغ اضافه‌دریافت نسبت به خالص فاکتور، به ارز خود فاکتور."""
total = Decimal(str(((document.extra_info or {}).get("totals") or {}).get("net", 0) or 0))
paid = Decimal(0)
for item in payments:
raw = item.get("settles_amount")
if raw is None:
raw = item.get("amount", 0)
amount = Decimal(str(raw or 0))
if amount > 0:
paid += amount
return max(Decimal(0), paid - total)
def _create_quick_sale_change_payment(
db: Session,
*,
business_id: int,
user_id: int,
document: Document,
person_id: int,
payments: List[Dict[str, Any]],
change_request: Dict[str, Any] | None,
) -> int | None:
"""ثبت وجه برگشتی فروش سریع از صندوق، در همان تراکنش ساخت فاکتور."""
change_amount = _quick_sale_change_amount(document, payments)
if change_amount <= Decimal("0.01"):
return None
request = dict(change_request) if isinstance(change_request, dict) else {}
if request.get("confirmed_returned") is not True:
raise ApiError(
"QUICK_SALE_CHANGE_CONFIRMATION_REQUIRED",
"باید پرداخت مبلغ قابل برگشت به مشتری تأیید شود",
http_status=400,
)
requested_amount = Decimal(str(request.get("amount", 0) or 0))
if abs(requested_amount - change_amount) > Decimal("0.01"):
raise ApiError(
"QUICK_SALE_CHANGE_AMOUNT_MISMATCH",
"مبلغ قابل برگشت با اضافه‌پرداخت فاکتور هم‌خوانی ندارد",
http_status=400,
)
try:
cash_register_id = int(request.get("cash_register_id"))
except (TypeError, ValueError) as exc:
raise ApiError(
"QUICK_SALE_CHANGE_CASH_REGISTER_REQUIRED",
"برای برگشت وجه انتخاب صندوق نقدی الزامی است",
http_status=400,
) from exc
register = (
db.query(CashRegister)
.filter(
CashRegister.id == cash_register_id,
CashRegister.business_id == int(business_id),
CashRegister.is_active == True, # noqa: E712
)
.first()
)
if not register:
raise ApiError(
"QUICK_SALE_CHANGE_CASH_REGISTER_NOT_FOUND",
"صندوق برگشت وجه یافت نشد یا غیرفعال است",
http_status=404,
)
if int(register.currency_id) != int(document.currency_id):
raise ApiError(
"QUICK_SALE_CHANGE_CURRENCY_MISMATCH",
"ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست",
http_status=400,
)
from app.services.receipt_payment_service import create_receipt_payment
rp = create_receipt_payment(
db=db,
business_id=int(business_id),
user_id=int(user_id),
commit=False,
data={
"document_type": "payment",
"document_date": document.document_date.isoformat(),
"currency_id": int(document.currency_id),
"description": f"برگشت اضافه‌دریافت فروش سریع فاکتور {document.code}",
"person_lines": [{
"person_id": int(person_id),
"amount": float(change_amount),
"description": f"وجه برگشتی فاکتور {document.code}",
}],
"account_lines": [{
"transaction_type": "cash_register",
"cash_register_id": cash_register_id,
"cash_register_name": register.name,
"amount": float(change_amount),
"transaction_date": document.document_date.isoformat(),
"description": f"برگشت اضافه‌دریافت فاکتور {document.code}",
}],
"extra_info": {
"source": "quick_sale_change",
"invoice_id": int(document.id),
"person_is_receivable": True,
"gross_received": float(
change_amount
+ Decimal(
str(
((document.extra_info or {}).get("totals") or {}).get(
"net", 0
)
or 0
)
)
),
"change_amount": float(change_amount),
},
},
)
if not isinstance(rp, dict) or not rp.get("id"):
raise ApiError(
"QUICK_SALE_CHANGE_CREATE_FAILED",
"ثبت سند برگشت وجه ناموفق بود",
http_status=500,
)
return int(rp["id"])
def _sum_receipt_payment_amounts_for_invoice(
db: Session,
business_id: int,
@ -2920,7 +3083,11 @@ def _clear_invoice_linked_receipt_payments(
delete_receipt_payment(db, int(rp_id), commit=False)
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links") or {})
change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
if change_payment_id is not None:
delete_receipt_payment(db, int(change_payment_id), commit=False)
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
@ -5608,12 +5775,39 @@ def create_invoice(
invoice_type=invoice_type,
)
if payment_docs:
logger.info(f"Linking payment_docs to invoice {document.id}: {payment_docs}")
change_payment_id: int | None = None
if (
not document.is_proforma
and is_quick_sale
and invoice_type == INVOICE_SALES
and payments
and isinstance(payments, list)
and person_id
):
change_payment_id = _create_quick_sale_change_payment(
db,
business_id=business_id,
user_id=user_id,
document=document,
person_id=int(person_id),
payments=payments,
change_request=extra_info_all.get("quick_sale_change"),
)
if payment_docs or change_payment_id:
logger.info(
"Linking payment docs to invoice %s: receipts=%s change=%s",
document.id,
payment_docs,
change_payment_id,
)
db.add(document)
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs
if payment_docs:
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs
if change_payment_id:
links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = change_payment_id
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
@ -5786,6 +5980,7 @@ def update_invoice(
_old_is_proforma = bool(document.is_proforma)
_old_receipt_payment_ids = list(_pre_links_wh.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or [])
_old_expense_income_ids = list(_pre_links_wh.get(INVOICE_LINK_EXPENSE_INCOME_IDS) or [])
_old_change_payment_id = _pre_links_wh.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
# Only editable in current fiscal year
fiscal_year = (
@ -5870,6 +6065,7 @@ def update_invoice(
db,
receipt_payment_ids=_old_receipt_payment_ids,
expense_income_ids=_old_expense_income_ids,
change_payment_id=_old_change_payment_id,
)
if document.is_proforma:
_strip_invoice_side_document_links_from_document(document)
@ -6649,6 +6845,15 @@ def update_invoice(
# هرگاه payments در payload باشد، اسناد دریافت/پرداخت قبلی حذف می‌شوند؛ خطای حذف باید به کاربر برگردد
for old_rp_id in old_receipt_payment_ids:
delete_receipt_payment(db, int(old_rp_id), commit=False)
if _old_change_payment_id is not None:
delete_receipt_payment(db, int(_old_change_payment_id), commit=False)
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
if not payments:
extra = dict(document.extra_info) if document.extra_info else {}
@ -6682,6 +6887,31 @@ def update_invoice(
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
is_quick_sale_pm = bool((document.extra_info or {}).get("quick_sale", False))
if payments and inv_type == INVOICE_SALES and is_quick_sale_pm and person_id_pm:
change_amount = _quick_sale_change_amount(document, payments)
if change_amount <= Decimal("0.01"):
extra = dict(document.extra_info) if document.extra_info else {}
extra.pop("quick_sale_change", None)
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
change_payment_id = _create_quick_sale_change_payment(
db,
business_id=int(document.business_id),
user_id=user_id,
document=document,
person_id=int(person_id_pm),
payments=payments,
change_request=(document.extra_info or {}).get("quick_sale_change"),
)
if change_payment_id:
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = change_payment_id
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
try:
_remove_old_invoice_warehouse_documents(
db,
@ -7029,7 +7259,10 @@ def delete_invoice(db: Session, document_id: int, *, commit: bool = True) -> boo
raise # خطا را propagate کن تا rollback شود
# 5) حذف خودکار همه اسناد دریافت/پرداخت مرتبط (بدون توجه به مبلغ) - بدون commit
receipt_payment_document_ids = links.get("receipt_payment_document_ids") or []
receipt_payment_document_ids = list(links.get("receipt_payment_document_ids") or [])
change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
if change_payment_id is not None:
receipt_payment_document_ids.append(change_payment_id)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found {len(receipt_payment_document_ids)} receipt/payment document IDs in links: {receipt_payment_document_ids}")
if receipt_payment_document_ids:
@ -7270,10 +7503,11 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
"""
try:
extra_info = document.extra_info or {}
links = extra_info.get('links', {})
receipt_payment_ids = links.get('receipt_payment_document_ids', [])
if not receipt_payment_ids:
links = extra_info.get("links", {})
receipt_payment_ids = links.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS, [])
change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
if not receipt_payment_ids and change_payment_id is None:
return False
# بررسی وجود واقعی هر سند
@ -7284,7 +7518,8 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
# بررسی وجود سند در دیتابیس
doc = db.query(Document).filter(
Document.id == doc_id_int,
Document.document_type.in_(['receipt', 'payment']),
Document.business_id == document.business_id,
Document.document_type.in_(["receipt", "payment"]),
).first()
if doc:
valid_ids.append(doc_id_int)
@ -7292,15 +7527,43 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
# شناسه نامعتبر، رد می‌شود
continue
valid_change_payment_id = None
parsed_change_id = None
if change_payment_id is not None:
try:
parsed_change_id = int(change_payment_id)
change_doc = db.query(Document).filter(
Document.id == parsed_change_id,
Document.business_id == document.business_id,
Document.document_type == "payment",
).first()
if change_doc:
valid_change_payment_id = parsed_change_id
except (ValueError, TypeError):
pass
# اگر همه لینک‌ها معتبر هستند، نیازی به به‌روزرسانی نیست
if len(valid_ids) == len(receipt_payment_ids):
if (
len(valid_ids) == len(receipt_payment_ids)
and (
change_payment_id is None
or (
parsed_change_id is not None
and valid_change_payment_id == parsed_change_id
)
)
):
return False
# به‌روزرسانی extra_info با لینک‌های معتبر
extra_info = dict(extra_info)
links = dict(links)
links['receipt_payment_document_ids'] = valid_ids
extra_info['links'] = links
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = valid_ids
if valid_change_payment_id is None:
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
else:
links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = valid_change_payment_id
extra_info["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra_info)
flag_modified(document, "extra_info")
@ -10098,7 +10361,7 @@ def _get_receipt_payment_ids_linked_to_invoice(
Integer,
)
rows = (
db.query(DocumentLine.document_id)
db.query(DocumentLine.document_id, Document.extra_info)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
Document.business_id == business_id,
@ -10110,6 +10373,9 @@ def _get_receipt_payment_ids_linked_to_invoice(
.all()
)
for row in rows:
row_extra = row[1] if isinstance(row[1], dict) else {}
if row_extra.get("source") == "quick_sale_change":
continue
ids.add(int(row[0]))
return list(ids)
@ -10297,6 +10563,12 @@ def batch_calculate_invoices_remaining(
link_doc_ids.add(int(doc_id))
except (ValueError, TypeError):
pass
change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
if change_doc_id is not None:
try:
link_doc_ids.add(int(change_doc_id))
except (ValueError, TypeError):
pass
item_lines_by_doc: Dict[int, List[InvoiceItemLine]] = defaultdict(list)
if need_item_lines:
@ -10383,7 +10655,23 @@ def batch_calculate_invoices_remaining(
if not rp_doc:
continue
processed_doc_ids.add(doc_id_int)
total_paid += _sum_receipt_doc_payments_for_invoice(rp_doc, inv_id, via_person_line=True)
if (rp_doc.extra_info or {}).get("source") == "quick_sale_change":
total_paid -= _sum_receipt_doc_account_payments(rp_doc)
else:
total_paid += _sum_receipt_doc_payments_for_invoice(
rp_doc, inv_id, via_person_line=True
)
change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
try:
parsed_change_doc_id = int(change_doc_id) if change_doc_id is not None else None
except (ValueError, TypeError):
parsed_change_doc_id = None
if parsed_change_doc_id is not None and parsed_change_doc_id not in processed_doc_ids:
change_doc = rp_docs_by_id.get(parsed_change_doc_id)
if change_doc:
processed_doc_ids.add(parsed_change_doc_id)
total_paid -= _sum_receipt_doc_account_payments(change_doc)
remaining = total_amount - total_paid
results[inv_id] = {
@ -10512,7 +10800,32 @@ def calculate_invoice_remaining(
if doc.id in processed_doc_ids:
continue
processed_doc_ids.add(doc.id)
total_paid += _sum_receipt_doc_payments_for_invoice(doc, invoice_id, via_person_line=True)
if (doc.extra_info or {}).get("source") == "quick_sale_change":
total_paid -= _sum_receipt_doc_account_payments(doc)
else:
total_paid += _sum_receipt_doc_payments_for_invoice(
doc, invoice_id, via_person_line=True
)
change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID)
try:
parsed_change_doc_id = int(change_doc_id) if change_doc_id is not None else None
except (ValueError, TypeError):
parsed_change_doc_id = None
if parsed_change_doc_id is not None and parsed_change_doc_id not in processed_doc_ids:
change_doc = (
db.query(Document)
.options(selectinload(Document.lines))
.filter(
Document.id == parsed_change_doc_id,
Document.business_id == business_id,
Document.document_type == "payment",
)
.first()
)
if change_doc:
processed_doc_ids.add(parsed_change_doc_id)
total_paid -= _sum_receipt_doc_account_payments(change_doc)
remaining = total_amount - total_paid

View file

@ -1,15 +1,20 @@
from __future__ import annotations
from typing import Any, Dict, List
import json
import logging
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
import re
from typing import Any, Dict, List
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from adapters.db.models.quick_sales_settings import QuickSalesSetting
from adapters.db.models.person import Person, PersonType
from adapters.db.models.business import Business
from adapters.api.v1.schema_models.person import PersonCreateRequest
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.business import Business
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.person import Person, PersonType
from adapters.db.models.quick_sales_payment_button import QuickSalesPaymentButton
from adapters.db.models.quick_sales_settings import QuickSalesSetting
from app.core.responses import ApiError
from app.services.person_service import create_person
@ -21,6 +26,417 @@ DEFAULT_SHARE_EXPIRY_HOURS = 168
MIN_SHARE_EXPIRY_HOURS = 1
MAX_SHARE_EXPIRY_HOURS = 720
PAYMENT_BUTTON_TARGETS = frozenset({"cash_register", "bank", "person"})
PAYMENT_BUTTON_SCOPES = frozenset({"business", "user"})
PAYMENT_BUTTON_RESERVED_SHORTCUTS = frozenset(
{"ENTER", "ESCAPE", "DELETE", "BACKSPACE", "F5", "+", "-"}
)
MAX_PAYMENT_BUTTONS_PER_SCOPE = 30
def _normalize_payment_button_shortcut(value: Any) -> str | None:
shortcut = str(value or "").strip().upper().replace(" ", "")
if not shortcut:
return None
if len(shortcut) > 32:
raise ApiError("PAYMENT_BUTTON_SHORTCUT_INVALID", "میانبر بیش از حد طولانی است", http_status=400)
if shortcut in PAYMENT_BUTTON_RESERVED_SHORTCUTS:
raise ApiError(
"PAYMENT_BUTTON_SHORTCUT_RESERVED",
"این کلید در فروش سریع یا مرورگر رزرو شده است",
http_status=400,
)
if not re.fullmatch(
r"(?:F(?:[1-9]|1[0-2])|(?:(?:CTRL|ALT|SHIFT)\+)+(?:[A-Z0-9]|F(?:[1-9]|1[0-2])))",
shortcut,
):
raise ApiError(
"PAYMENT_BUTTON_SHORTCUT_INVALID",
"میانبر باید یکی از کلیدهای F یا ترکیبی مانند Alt+1 باشد",
http_status=400,
)
return shortcut
def _payment_button_target(
db: Session,
business_id: int,
target_type: str,
target_id: Any,
) -> tuple[Any, str]:
try:
parsed_id = int(target_id)
except (TypeError, ValueError) as exc:
raise ApiError("PAYMENT_BUTTON_TARGET_REQUIRED", "مقصد پرداخت الزامی است", http_status=400) from exc
if target_type == "cash_register":
target = (
db.query(CashRegister)
.filter(
CashRegister.id == parsed_id,
CashRegister.business_id == int(business_id),
CashRegister.is_active == True, # noqa: E712
)
.first()
)
name = getattr(target, "name", None)
elif target_type == "bank":
target = (
db.query(BankAccount)
.filter(
BankAccount.id == parsed_id,
BankAccount.business_id == int(business_id),
BankAccount.is_active == True, # noqa: E712
)
.first()
)
name = getattr(target, "name", None)
elif target_type == "person":
target = (
db.query(Person)
.filter(Person.id == parsed_id, Person.business_id == int(business_id))
.first()
)
name = getattr(target, "alias_name", None)
else:
raise ApiError("PAYMENT_BUTTON_TARGET_TYPE_INVALID", "نوع مقصد پرداخت نامعتبر است", http_status=400)
if not target:
raise ApiError("PAYMENT_BUTTON_TARGET_NOT_FOUND", "مقصد پرداخت یافت نشد یا غیرفعال است", http_status=404)
return target, str(name or "مقصد پرداخت")
def _payment_button_target_id(row: QuickSalesPaymentButton) -> int:
if row.target_type == "cash_register":
return int(row.cash_register_id)
if row.target_type == "bank":
return int(row.bank_account_id)
return int(row.person_id)
def _payment_button_to_dict(db: Session, row: QuickSalesPaymentButton) -> Dict[str, Any]:
_, target_name = _payment_button_target(
db, row.business_id, row.target_type, _payment_button_target_id(row)
)
# برای شخص، خود نام شخص روی دکمه نمایش داده می‌شود تا عنوان مبهمی مثل «کارت شخصی» نسازیم.
label = target_name if row.target_type == "person" else (row.label or target_name)
return {
"id": int(row.id),
"business_id": int(row.business_id),
"scope": row.scope,
"owner_user_id": int(row.owner_user_id) if row.owner_user_id else None,
"target_type": row.target_type,
"target_id": _payment_button_target_id(row),
"target_name": target_name,
"label": label,
"shortcut": row.shortcut,
"position": int(row.position),
"is_active": bool(row.is_active),
"is_system": False,
}
def _assert_payment_button_shortcut_available(
db: Session,
*,
business_id: int,
scope: str,
owner_user_id: int | None,
shortcut: str | None,
exclude_id: int | None = None,
) -> None:
if not shortcut:
return
query = db.query(QuickSalesPaymentButton).filter(
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.is_active == True, # noqa: E712
func.upper(QuickSalesPaymentButton.shortcut) == shortcut,
)
if exclude_id is not None:
query = query.filter(QuickSalesPaymentButton.id != int(exclude_id))
if scope == "business":
query = query.filter(
or_(
QuickSalesPaymentButton.scope == "business",
QuickSalesPaymentButton.scope == "user",
)
)
else:
query = query.filter(
or_(
QuickSalesPaymentButton.scope == "business",
and_(
QuickSalesPaymentButton.scope == "user",
QuickSalesPaymentButton.owner_user_id == int(owner_user_id),
),
)
)
if query.first():
raise ApiError(
"PAYMENT_BUTTON_SHORTCUT_CONFLICT",
"این میانبر قبلاً برای یک دکمه پرداخت استفاده شده است",
http_status=409,
)
def list_quick_sales_payment_buttons(
db: Session, business_id: int, user_id: int
) -> List[Dict[str, Any]]:
rows = (
db.query(QuickSalesPaymentButton)
.filter(
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.is_active == True, # noqa: E712
or_(
QuickSalesPaymentButton.scope == "business",
and_(
QuickSalesPaymentButton.scope == "user",
QuickSalesPaymentButton.owner_user_id == int(user_id),
),
),
)
.order_by(QuickSalesPaymentButton.position.asc(), QuickSalesPaymentButton.id.asc())
.all()
)
result = [_payment_button_to_dict(db, row) for row in rows]
if result:
return result
settings = (
db.query(QuickSalesSetting)
.filter(QuickSalesSetting.business_id == int(business_id))
.first()
)
register_id = getattr(settings, "default_cash_register_id", None)
if not register_id:
return []
try:
_, name = _payment_button_target(db, business_id, "cash_register", register_id)
except ApiError:
return []
return [{
"id": f"system-cash-{int(register_id)}",
"business_id": int(business_id),
"scope": "system",
"owner_user_id": None,
"target_type": "cash_register",
"target_id": int(register_id),
"target_name": name,
"label": name,
"shortcut": None,
"position": 0,
"is_active": True,
"is_system": True,
}]
def create_quick_sales_payment_button(
db: Session,
*,
business_id: int,
user_id: int,
payload: Dict[str, Any],
can_manage_business: bool,
) -> Dict[str, Any]:
scope = str(payload.get("scope") or "user").strip().lower()
if scope not in PAYMENT_BUTTON_SCOPES:
raise ApiError("PAYMENT_BUTTON_SCOPE_INVALID", "دامنه دکمه نامعتبر است", http_status=400)
if scope == "business" and not can_manage_business:
raise ApiError("FORBIDDEN", "مجوز تعریف دکمه مشترک را ندارید", http_status=403)
target_type = str(payload.get("target_type") or "").strip().lower()
if target_type not in PAYMENT_BUTTON_TARGETS:
raise ApiError("PAYMENT_BUTTON_TARGET_TYPE_INVALID", "نوع مقصد پرداخت نامعتبر است", http_status=400)
target, target_name = _payment_button_target(
db, business_id, target_type, payload.get("target_id")
)
owner_user_id = int(user_id) if scope == "user" else None
count = (
db.query(func.count(QuickSalesPaymentButton.id))
.filter(
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.scope == scope,
QuickSalesPaymentButton.owner_user_id == owner_user_id,
QuickSalesPaymentButton.is_active == True, # noqa: E712
)
.scalar()
or 0
)
if int(count) >= MAX_PAYMENT_BUTTONS_PER_SCOPE:
raise ApiError("PAYMENT_BUTTON_LIMIT", "حداکثر تعداد دکمه‌های پرداخت ثبت شده است", http_status=400)
shortcut = _normalize_payment_button_shortcut(payload.get("shortcut"))
_assert_payment_button_shortcut_available(
db,
business_id=business_id,
scope=scope,
owner_user_id=owner_user_id,
shortcut=shortcut,
)
max_position = (
db.query(func.max(QuickSalesPaymentButton.position))
.filter(
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.scope == scope,
QuickSalesPaymentButton.owner_user_id == owner_user_id,
)
.scalar()
)
label = str(payload.get("label") or "").strip() or target_name
if target_type == "person":
label = target_name
row = QuickSalesPaymentButton(
business_id=int(business_id),
scope=scope,
owner_user_id=owner_user_id,
target_type=target_type,
cash_register_id=int(target.id) if target_type == "cash_register" else None,
bank_account_id=int(target.id) if target_type == "bank" else None,
person_id=int(target.id) if target_type == "person" else None,
label=label[:120],
shortcut=shortcut,
position=(int(max_position) + 1) if max_position is not None else 0,
is_active=True,
created_by_user_id=int(user_id),
)
db.add(row)
db.commit()
db.refresh(row)
return _payment_button_to_dict(db, row)
def update_quick_sales_payment_button(
db: Session,
*,
business_id: int,
button_id: int,
user_id: int,
payload: Dict[str, Any],
can_manage_business: bool,
) -> Dict[str, Any]:
row = (
db.query(QuickSalesPaymentButton)
.filter(
QuickSalesPaymentButton.id == int(button_id),
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.is_active == True, # noqa: E712
)
.first()
)
if not row:
raise ApiError("PAYMENT_BUTTON_NOT_FOUND", "دکمه پرداخت یافت نشد", http_status=404)
if row.scope == "business" and not can_manage_business:
raise ApiError("FORBIDDEN", "مجوز ویرایش دکمه مشترک را ندارید", http_status=403)
if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id):
raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403)
target_type = str(payload.get("target_type") or row.target_type).strip().lower()
target_id = payload.get("target_id", _payment_button_target_id(row))
target, target_name = _payment_button_target(db, business_id, target_type, target_id)
shortcut = _normalize_payment_button_shortcut(payload.get("shortcut", row.shortcut))
_assert_payment_button_shortcut_available(
db,
business_id=business_id,
scope=row.scope,
owner_user_id=row.owner_user_id,
shortcut=shortcut,
exclude_id=row.id,
)
row.target_type = target_type
row.cash_register_id = int(target.id) if target_type == "cash_register" else None
row.bank_account_id = int(target.id) if target_type == "bank" else None
row.person_id = int(target.id) if target_type == "person" else None
row.label = (
target_name
if target_type == "person"
else (str(payload.get("label") or row.label or target_name).strip() or target_name)[:120]
)
row.shortcut = shortcut
if "position" in payload:
row.position = max(0, int(payload.get("position") or 0))
db.commit()
db.refresh(row)
return _payment_button_to_dict(db, row)
def delete_quick_sales_payment_button(
db: Session,
*,
business_id: int,
button_id: int,
user_id: int,
can_manage_business: bool,
) -> None:
row = (
db.query(QuickSalesPaymentButton)
.filter(
QuickSalesPaymentButton.id == int(button_id),
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.is_active == True, # noqa: E712
)
.first()
)
if not row:
raise ApiError("PAYMENT_BUTTON_NOT_FOUND", "دکمه پرداخت یافت نشد", http_status=404)
if row.scope == "business" and not can_manage_business:
raise ApiError("FORBIDDEN", "مجوز حذف دکمه مشترک را ندارید", http_status=403)
if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id):
raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403)
row.is_active = False
db.commit()
def reorder_quick_sales_payment_buttons(
db: Session,
*,
business_id: int,
user_id: int,
button_ids: List[Any],
can_manage_business: bool,
) -> List[Dict[str, Any]]:
"""ترتیب مؤثر دکمه‌های مشترک و شخصی کاربر را ذخیره می‌کند."""
try:
ordered_ids = [int(value) for value in button_ids]
except (TypeError, ValueError) as exc:
raise ApiError(
"PAYMENT_BUTTON_ORDER_INVALID",
"ترتیب دکمه‌های پرداخت نامعتبر است",
http_status=400,
) from exc
if len(ordered_ids) != len(set(ordered_ids)):
raise ApiError(
"PAYMENT_BUTTON_ORDER_INVALID",
"شناسه تکراری در ترتیب دکمه‌ها وجود دارد",
http_status=400,
)
rows = (
db.query(QuickSalesPaymentButton)
.filter(
QuickSalesPaymentButton.business_id == int(business_id),
QuickSalesPaymentButton.id.in_(ordered_ids),
QuickSalesPaymentButton.is_active == True, # noqa: E712
)
.all()
)
by_id = {int(row.id): row for row in rows}
if set(by_id) != set(ordered_ids):
raise ApiError(
"PAYMENT_BUTTON_NOT_FOUND",
"یکی از دکمه‌های پرداخت یافت نشد",
http_status=404,
)
for row in rows:
if row.scope == "business" and not can_manage_business:
raise ApiError(
"FORBIDDEN",
"مجوز تغییر ترتیب دکمه‌های مشترک را ندارید",
http_status=403,
)
if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id):
raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403)
for position, button_id in enumerate(ordered_ids):
by_id[button_id].position = position
db.commit()
return list_quick_sales_payment_buttons(db, business_id, user_id)
def normalize_default_share_channels(value: Any) -> List[str]:
"""نرمال‌سازی لیست کانال‌های اشتراک‌گذاری."""

View file

@ -828,8 +828,9 @@ def create_receipt_payment(
logger.info(f"انتخاب حساب چک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_transaction_type == "person":
# برای شخص، از حساب شخص عمومی استفاده کن
account_code = "20201" # حساب‌های پرداختنی
# دریافت نزد شخص یعنی شخص وجه کسب‌وکار را نگه می‌دارد و به کسب‌وکار بدهکار است.
# در پرداخت به شخص، حساب پرداختنی شخص استفاده می‌شود.
account_code = "10401" if is_receipt else "20201"
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_transaction_type == "wallet":
@ -2152,7 +2153,10 @@ def update_receipt_payment(
elif normalized_transaction_type == "check":
account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202")
elif normalized_transaction_type == "person":
account = _get_fixed_account_by_code(db, "20201")
account = _get_fixed_account_by_code(
db,
"10401" if is_receipt else "20201",
)
elif account_line.get("account_id"):
account = db.query(Account).filter(
and_(
@ -2832,4 +2836,3 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}

View file

@ -0,0 +1,89 @@
"""Quick-sales configurable payment buttons.
Revision ID: 20261013_000001_quick_sales_payment_buttons
Revises: 20261012_000001_quick_sales_stock_shortage
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20261013_000001_quick_sales_payment_buttons"
down_revision = "20261012_000001_quick_sales_stock_shortage"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"quick_sales_payment_buttons",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("business_id", sa.Integer(), nullable=False),
sa.Column("scope", sa.String(length=16), nullable=False),
sa.Column("owner_user_id", sa.Integer(), nullable=True),
sa.Column("target_type", sa.String(length=24), nullable=False),
sa.Column("cash_register_id", sa.Integer(), nullable=True),
sa.Column("bank_account_id", sa.Integer(), nullable=True),
sa.Column("person_id", sa.Integer(), nullable=True),
sa.Column("label", sa.String(length=120), nullable=True),
sa.Column("shortcut", sa.String(length=32), nullable=True),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("scope IN ('business', 'user')", name="ck_qspb_scope"),
sa.CheckConstraint(
"(scope = 'business' AND owner_user_id IS NULL) OR "
"(scope = 'user' AND owner_user_id IS NOT NULL)",
name="ck_qspb_scope_owner",
),
sa.CheckConstraint(
"(target_type = 'cash_register' AND cash_register_id IS NOT NULL "
"AND bank_account_id IS NULL AND person_id IS NULL) OR "
"(target_type = 'bank' AND bank_account_id IS NOT NULL "
"AND cash_register_id IS NULL AND person_id IS NULL) OR "
"(target_type = 'person' AND person_id IS NOT NULL "
"AND cash_register_id IS NULL AND bank_account_id IS NULL)",
name="ck_qspb_exact_target",
),
sa.ForeignKeyConstraint(["business_id"], ["businesses.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["cash_register_id"], ["cash_registers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["bank_account_id"], ["bank_accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["person_id"], ["persons.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_qspb_business_active_position",
"quick_sales_payment_buttons",
["business_id", "is_active", "position"],
)
op.create_index(
"ix_quick_sales_payment_buttons_business_id",
"quick_sales_payment_buttons",
["business_id"],
)
op.create_index(
"ix_quick_sales_payment_buttons_owner_user_id",
"quick_sales_payment_buttons",
["owner_user_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_quick_sales_payment_buttons_owner_user_id",
table_name="quick_sales_payment_buttons",
)
op.drop_index(
"ix_qspb_business_active_position",
table_name="quick_sales_payment_buttons",
)
op.drop_index(
"ix_quick_sales_payment_buttons_business_id",
table_name="quick_sales_payment_buttons",
)
op.drop_table("quick_sales_payment_buttons")

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from types import SimpleNamespace
from app.services.invoice_service import _quick_sale_change_amount
def _invoice(total: int):
return SimpleNamespace(extra_info={"totals": {"net": total}})
def test_quick_sale_change_is_zero_when_payments_do_not_exceed_total() -> None:
assert _quick_sale_change_amount(
_invoice(1_000_000),
[{"amount": 400_000}, {"amount": 600_000}],
) == 0
def test_quick_sale_change_returns_overpayment() -> None:
assert _quick_sale_change_amount(
_invoice(1_000_000),
[{"amount": 700_000}, {"amount": 450_000}],
) == 150_000
def test_quick_sale_change_uses_settlement_amount_for_foreign_payment() -> None:
assert _quick_sale_change_amount(
_invoice(1_000_000),
[{"amount": 10, "settles_amount": 1_100_000}],
) == 100_000

View file

@ -14,10 +14,11 @@ from app.core.responses import ApiError
from app.services.quick_sales_service import (
DEFAULT_SHARE_CHANNELS,
DEFAULT_SHARE_EXPIRY_HOURS,
_normalize_payment_button_shortcut,
_share_settings_from_obj,
get_quick_sales_settings,
normalize_default_share_channels,
normalize_share_expiry_hours,
get_quick_sales_settings,
_share_settings_from_obj,
)
@ -101,3 +102,17 @@ def test_validate_share_gateway_id_rejects_unknown_gateway() -> None:
with pytest.raises(ApiError) as exc:
_validate_share_gateway_id(db, 1, 99)
assert exc.value.detail["error"]["code"] == "GATEWAY_NOT_FOUND"
@pytest.mark.parametrize(
("raw", "expected"),
[("alt + 1", "ALT+1"), ("F3", "F3"), ("ctrl+shift+k", "CTRL+SHIFT+K")],
)
def test_normalize_payment_button_shortcut(raw: str, expected: str) -> None:
assert _normalize_payment_button_shortcut(raw) == expected
@pytest.mark.parametrize("raw", ["F5", "Enter", "plain text", "Ctrl+"])
def test_normalize_payment_button_shortcut_rejects_invalid_or_reserved(raw: str) -> None:
with pytest.raises(ApiError):
_normalize_payment_button_shortcut(raw)

View file

@ -0,0 +1,62 @@
enum QuickSalesPaymentTargetType { cashRegister, bank, person }
class QuickSalesPaymentButtonModel {
final Object id;
final int businessId;
final String scope;
final int? ownerUserId;
final QuickSalesPaymentTargetType targetType;
final int targetId;
final String targetName;
final String label;
final String? shortcut;
final int position;
final bool isSystem;
const QuickSalesPaymentButtonModel({
required this.id,
required this.businessId,
required this.scope,
required this.ownerUserId,
required this.targetType,
required this.targetId,
required this.targetName,
required this.label,
required this.shortcut,
required this.position,
required this.isSystem,
});
bool get isPersonal => scope == 'user';
factory QuickSalesPaymentButtonModel.fromJson(Map<String, dynamic> json) {
final target = switch ((json['target_type'] ?? '').toString()) {
'cash_register' => QuickSalesPaymentTargetType.cashRegister,
'bank' => QuickSalesPaymentTargetType.bank,
'person' => QuickSalesPaymentTargetType.person,
_ => throw const FormatException(
'Unsupported quick-sales payment target',
),
};
return QuickSalesPaymentButtonModel(
id: json['id'] ?? '',
businessId: (json['business_id'] as num).toInt(),
scope: (json['scope'] ?? 'user').toString(),
ownerUserId: (json['owner_user_id'] as num?)?.toInt(),
targetType: target,
targetId: (json['target_id'] as num).toInt(),
targetName: (json['target_name'] ?? '').toString(),
label: (json['label'] ?? json['target_name'] ?? '').toString(),
shortcut: json['shortcut']?.toString(),
position: (json['position'] as num?)?.toInt() ?? 0,
isSystem: json['is_system'] == true,
);
}
static String targetTypeValue(QuickSalesPaymentTargetType type) =>
switch (type) {
QuickSalesPaymentTargetType.cashRegister => 'cash_register',
QuickSalesPaymentTargetType.bank => 'bank',
QuickSalesPaymentTargetType.person => 'person',
};
}

View file

@ -33,6 +33,7 @@ import '../../models/quick_sales_parked_sale.dart';
import '../../widgets/invoice/customer_combobox_widget.dart';
import '../../widgets/quick_sales/quick_sales_parked_sales_bar.dart';
import '../../widgets/quick_sales/quick_sales_payment_composer.dart';
import '../../widgets/quick_sales/quick_sales_payment_dock.dart';
import '../../widgets/invoice/warehouse_combobox_widget.dart';
import '../../utils/currency_display_utils.dart';
import '../../widgets/permission/access_denied_page.dart';
@ -158,7 +159,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
List<InvoiceTransaction> _payments = [];
String? _selectedCashRegisterId;
bool _autoCreatePaymentDocument = true;
bool _cashFollowsTotal = true;
bool _cashFollowsTotal = false;
bool _shareRemainingEnabled = false;
// اشتراک‌گذاری (وقتی سند دریافت ثبت نمی‌شود)
@ -698,7 +699,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_selectedCustomer = _anonymousCustomer;
_cartItems = [];
_payments = [];
_cashFollowsTotal = true;
_cashFollowsTotal = false;
_shareRemainingEnabled = false;
_checkoutDiscountExpanded = false;
_selectedCashRegisterId = _settingsDefaultCashRegisterId;
@ -1042,14 +1043,15 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_autoCreatePaymentDocument = value;
_shareRemainingEnabled = false;
_payments = [];
_cashFollowsTotal = true;
_cashFollowsTotal = false;
if (!value) {
_syncShareChannelDefaults();
}
});
}
num get _paidAmount => _payments.fold<num>(0, (sum, p) => sum + p.amount);
num get _paidAmount =>
_payments.fold<num>(0, (sum, p) => sum + p.settlesAgainstInvoice);
num get _paymentEpsilon {
if (_invoiceCurrencyDecimalPlaces <= 0) return 0.5;
@ -1071,6 +1073,8 @@ class _QuickSalesPageState extends State<QuickSalesPage>
_autoCreatePaymentDocument &&
_paidAmount > _totalAmount + _paymentEpsilon;
num get _changeAmount => _isOverpaid ? _paidAmount - _totalAmount : 0;
bool get _isAnonymousSelected {
if (_selectedCustomer == null || _anonymousCustomer == null) {
return _selectedCustomer == null;
@ -1139,6 +1143,11 @@ class _QuickSalesPageState extends State<QuickSalesPage>
if (id != null) map['check_id'] = id;
if (p.checkNumber != null) map['check_number'] = p.checkNumber;
break;
case TransactionType.person:
final id = int.tryParse(p.personId ?? '');
if (id != null) map['person_id'] = id;
if (p.personName != null) map['person_name'] = p.personName;
break;
default:
break;
}
@ -1149,7 +1158,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
String? _validatePayments(AppLocalizations t) {
if (!_autoCreatePaymentDocument) return null;
if (_isOverpaid) return t.quickSalesPayOverpaidError;
for (final p in _payments) {
if (p.amount <= 0) continue;
switch (p.type) {
@ -1168,6 +1176,11 @@ class _QuickSalesPageState extends State<QuickSalesPage>
return t.quickSalesPaySelectCheck;
}
break;
case TransactionType.person:
if (int.tryParse(p.personId ?? '') == null) {
return t.quickSalesPayNeedDestination;
}
break;
default:
return t.quickSalesPayNeedDestination;
}
@ -2470,6 +2483,8 @@ class _QuickSalesPageState extends State<QuickSalesPage>
Future<void> _saveInvoice({
bool print = false,
Map<String, dynamic>? stockOverride,
bool changeConfirmed = false,
bool debtConfirmed = false,
}) async {
// اعتبارسنجی سبد خرید
if (_cartItems.isEmpty) {
@ -2507,6 +2522,64 @@ class _QuickSalesPageState extends State<QuickSalesPage>
return;
}
}
if (_autoCreatePaymentDocument &&
_payments.isEmpty &&
_totalAmount > 0 &&
!debtConfirmed) {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('ثبت بدون دریافت'),
content: const Text(
'هیچ پرداختی ثبت نشده است و کل مبلغ فاکتور به حساب مشتری بدهکار می‌شود. ادامه می‌دهید؟',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('انصراف'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('ثبت نسیه'),
),
],
),
);
if (!mounted || confirmed != true) return;
debtConfirmed = true;
}
if (_isOverpaid && !changeConfirmed) {
if (int.tryParse(_selectedCashRegisterId ?? '') == null) {
SnackBarHelper.show(
context,
message:
'برای ثبت مبلغ قابل برگشت، صندوق نقدی پیش‌فرض را انتخاب کنید.',
isError: true,
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('تأیید برگشت وجه'),
content: Text(
'${_formatNumber(_changeAmount)} $_paymentCurrencyUnit از صندوق نقدی به مشتری برگردانده شده است؟',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('انصراف'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('تأیید برگشت وجه'),
),
],
),
);
if (!mounted || confirmed != true) return;
changeConfirmed = true;
}
if (_shouldShareAfterSave) {
if (_shareOnlinePaymentEnabled) {
if (_shareGateways.isEmpty) {
@ -2643,6 +2716,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
'tax': _totalTax,
'net': _totalAmount,
},
if (_isOverpaid)
'quick_sale_change': {
'amount': _changeAmount,
'cash_register_id': int.tryParse(_selectedCashRegisterId ?? ''),
'confirmed_returned': changeConfirmed,
},
};
final gvSave = _parsedGlobalDiscountValue;
@ -2683,7 +2762,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
setState(() {
_cartItems.clear();
_payments = [];
_cashFollowsTotal = true;
_cashFollowsTotal = false;
_shareRemainingEnabled = false;
_checkoutDiscountExpanded = false;
_productStocks.clear();
@ -2773,7 +2852,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
if (parkAfterFailure) {
_startNewSale();
} else if (retryAfterCorrection || retryOverride != null) {
await _saveInvoice(print: print, stockOverride: retryOverride);
await _saveInvoice(
print: print,
stockOverride: retryOverride,
changeConfirmed: changeConfirmed,
debtConfirmed: debtConfirmed,
);
}
}
@ -4278,12 +4362,40 @@ class _QuickSalesPageState extends State<QuickSalesPage>
);
return _withParkedSalesBar(
Row(
Column(
children: [
// ستون چپ: سبد خرید
Expanded(flex: 7, child: cartColumn),
// ستون راست: خلاصه و پرداخت
_buildDesktopCheckoutPanel(cs),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ستون اصلی: سبد خرید
Expanded(flex: 7, child: cartColumn),
// ستون کناری: مشتری، جمع و جزئیات سند
_buildDesktopCheckoutPanel(cs),
],
),
),
if (_autoCreatePaymentDocument)
QuickSalesPaymentDock(
key: ValueKey('payment-dock-$_activeSaleId'),
businessId: widget.businessId,
authStore: widget.authStore,
payments: _payments,
onChanged: _onPaymentsChanged,
invoiceTotal: _totalAmount,
defaultCashRegisterId: _selectedCashRegisterId,
currencyUnit: _paymentCurrencyUnit,
decimalPlaces: _invoiceCurrencyDecimalPlaces,
enabled: !_isSaving,
onSave: () => _saveInvoice(print: false),
onSaveAndPrint: () => _saveInvoice(print: true),
)
else
Material(
elevation: 3,
color: cs.surface,
child: _buildCheckoutButtons(cs),
),
],
),
compact: isCompact,
@ -5089,28 +5201,18 @@ class _QuickSalesPageState extends State<QuickSalesPage>
return Container(
width: 384,
color: cs.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Scrollbar(
thumbVisibility: true,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: _buildCheckoutScrollBody(
cs,
includeCustomer: true,
includeDocumentMeta: true,
),
),
),
child: Scrollbar(
thumbVisibility: true,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: _buildCheckoutScrollBody(
cs,
includeCustomer: true,
includeDocumentMeta: true,
includePayment: false,
includeTotal: false,
),
Material(
elevation: 2,
color: cs.surface,
child: _buildCheckoutButtons(cs),
),
],
),
),
);
}
@ -5119,6 +5221,8 @@ class _QuickSalesPageState extends State<QuickSalesPage>
ColorScheme cs, {
required bool includeCustomer,
required bool includeDocumentMeta,
bool includePayment = true,
bool includeTotal = true,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -5142,13 +5246,50 @@ class _QuickSalesPageState extends State<QuickSalesPage>
),
const SizedBox(height: 10),
],
_buildCheckoutTotalHero(cs),
const SizedBox(height: 4),
if (includeTotal) ...[
_buildCheckoutTotalHero(cs),
const SizedBox(height: 4),
],
_buildCheckoutDiscountBlock(cs),
const SizedBox(height: 10),
_buildPaymentSection(cs),
const SizedBox(height: 4),
if (includePayment) ...[
_buildPaymentSection(cs),
const SizedBox(height: 4),
],
_buildCheckoutDetailsTile(cs, includeDocumentMeta: includeDocumentMeta),
if (!includePayment) ...[
const SizedBox(height: 8),
_buildDesktopShareControls(cs),
],
],
);
}
Widget _buildDesktopShareControls(ColorScheme cs) {
final t = AppLocalizations.of(context);
if (!_autoCreatePaymentDocument) return _buildShareSection(cs, t);
if (_remainingAmount <= 0 || _isOverpaid) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
dense: true,
title: Tooltip(
message: t.quickSalesPayShareRemainingHint,
child: Text(t.quickSalesPayShareRemaining),
),
value: _shareRemainingEnabled,
onChanged: _isSaving
? null
: (value) {
setState(() {
_shareRemainingEnabled = value;
if (value) _syncShareChannelDefaults();
});
},
),
if (_shareRemainingEnabled) _buildShareSection(cs, t),
],
);
}
@ -5639,190 +5780,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
);
}
Widget _buildMobileBottomBar(ColorScheme cs) {
final canCheckout = _cartItems.isNotEmpty && !_isSaving;
final narrow = MediaQuery.sizeOf(context).width < _compactBreakpoint;
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: narrow ? 8 : 12,
vertical: narrow ? 8 : 10,
),
decoration: BoxDecoration(
color: cs.surface,
border: Border(
top: BorderSide(color: cs.outlineVariant.withOpacity(0.4)),
),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'مبلغ نهایی',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 2),
Text(
_formatNumber(_totalAmount),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: cs.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: narrow ? 8 : 12),
FilledButton(
onPressed: canCheckout ? () => _openCheckoutSheet(cs) : null,
style: narrow
? FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
)
: null,
child: _isSaving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: Text(
_cartItems.isEmpty
? 'سبد خالی'
: (narrow ? 'تسویه' : 'پرداخت / ثبت'),
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
enabled: canCheckout,
tooltip: 'اقدامات سریع',
onSelected: (value) async {
switch (value) {
case 'save':
await _saveInvoice(print: false);
break;
case 'print':
await _saveInvoice(print: true);
break;
}
},
itemBuilder: (context) => const [
PopupMenuItem<String>(
value: 'save',
child: Row(
children: [
Icon(Icons.save),
SizedBox(width: 10),
Text('ثبت'),
],
),
),
PopupMenuItem<String>(
value: 'print',
child: Row(
children: [
Icon(Icons.print),
SizedBox(width: 10),
Text('ثبت و چاپ'),
],
),
),
],
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
Icons.more_vert,
color: canCheckout
? cs.onSurface
: cs.onSurface.withOpacity(0.4),
),
),
),
),
],
),
),
);
}
Future<void> _openCheckoutSheet(ColorScheme cs) async {
if (_cartItems.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
backgroundColor: cs.surface,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.72,
minChildSize: 0.45,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Column(
children: [
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.only(bottom: 12),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: _buildCheckoutScrollBody(
cs,
includeCustomer: true,
includeDocumentMeta: true,
),
),
],
),
),
Container(
decoration: BoxDecoration(
color: cs.surface,
border: Border(
top: BorderSide(
color: cs.outlineVariant.withOpacity(0.4),
),
),
),
child: SafeArea(top: false, child: _buildCheckoutButtons(cs)),
),
],
);
},
);
},
);
}
Widget _buildSummaryRow(
String label,
String value, {

View file

@ -1,4 +1,5 @@
import '../core/api_client.dart';
import '../models/quick_sales_payment_button.dart';
class QuickSalesService {
final ApiClient _api;
@ -35,6 +36,80 @@ class QuickSalesService {
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
}
Future<List<QuickSalesPaymentButtonModel>> listPaymentButtons({
required int businessId,
}) async {
final res = await _api.get<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/quick-sales/payment-buttons',
);
final raw = res.data?['data'];
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map(
(e) => QuickSalesPaymentButtonModel.fromJson(
Map<String, dynamic>.from(e),
),
)
.toList();
}
Future<QuickSalesPaymentButtonModel> createPaymentButton({
required int businessId,
required Map<String, dynamic> payload,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/quick-sales/payment-buttons',
data: payload,
);
return QuickSalesPaymentButtonModel.fromJson(
Map<String, dynamic>.from(res.data?['data'] ?? const {}),
);
}
Future<QuickSalesPaymentButtonModel> updatePaymentButton({
required int businessId,
required int buttonId,
required Map<String, dynamic> payload,
}) async {
final res = await _api.put<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/quick-sales/payment-buttons/$buttonId',
data: payload,
);
return QuickSalesPaymentButtonModel.fromJson(
Map<String, dynamic>.from(res.data?['data'] ?? const {}),
);
}
Future<void> deletePaymentButton({
required int businessId,
required int buttonId,
}) async {
await _api.delete<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/quick-sales/payment-buttons/$buttonId',
);
}
Future<List<QuickSalesPaymentButtonModel>> reorderPaymentButtons({
required int businessId,
required List<int> buttonIds,
}) async {
final res = await _api.put<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/quick-sales/payment-buttons/reorder',
data: {'button_ids': buttonIds},
);
final raw = res.data?['data'];
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map(
(row) => QuickSalesPaymentButtonModel.fromJson(
Map<String, dynamic>.from(row),
),
)
.toList();
}
Future<Map<String, dynamic>> createStockCorrection({
required int businessId,
required Map<String, dynamic> payload,

View file

@ -27,7 +27,7 @@ bool canAddQuickSalesPaymentLine({
required List<InvoiceTransaction> payments,
required bool enabled,
required num epsilon,
int maxLines = 6,
int maxLines = 50,
}) {
if (!enabled) return false;
if (payments.length >= maxLines) return false;
@ -37,11 +37,10 @@ bool canAddQuickSalesPaymentLine({
return true;
}
/// اعمال مبلغ روی یک ردیف و حفظ تراز با فاکتور.
/// اعمال مبلغ روی یک ردیف پرداخت.
///
/// اگر جمع از مبلغ فاکتور بیشتر شود، از ردیف‌های دیگر (از آخر)
/// کم می‌شود تا دفاتر از حد فاکتور رد نشوند. ردیف‌های دیگر که صفر
/// شدند حذف می‌شوند. ردیف در حال ویرایش حتی با مبلغ صفر می‌ماند.
/// پرداخت‌ها مستقل از یکدیگر نگه داشته می‌شوند. جمع بیشتر از مبلغ
/// فاکتور مجاز است و در فروش سریع به صورت وجه برگشتی ثبت می‌شود.
List<InvoiceTransaction> applyQuickSalesPaymentAmount({
required List<InvoiceTransaction> payments,
required int index,
@ -53,35 +52,8 @@ List<InvoiceTransaction> applyQuickSalesPaymentAmount({
return List<InvoiceTransaction>.from(payments);
}
final epsilon = quickSalesPaymentEpsilon(decimalPlaces);
final next = List<InvoiceTransaction>.from(payments);
final safe = amount < 0 ? 0 : roundQuickSalesMoney(amount, decimalPlaces);
next[index] = next[index].copyWith(amount: safe);
var paid = next.fold<num>(0, (sum, p) => sum + p.amount);
if (paid > invoiceTotal + epsilon) {
var excess = roundQuickSalesMoney(paid - invoiceTotal, decimalPlaces);
for (var i = next.length - 1; i >= 0; i--) {
if (i == index) continue;
if (excess <= epsilon) break;
final reducible = next[i].amount;
if (reducible <= epsilon) continue;
final take = reducible < excess ? reducible : excess;
next[i] = next[i].copyWith(
amount: roundQuickSalesMoney(next[i].amount - take, decimalPlaces),
);
excess = roundQuickSalesMoney(excess - take, decimalPlaces);
}
if (excess > epsilon) {
final capped = roundQuickSalesMoney(
next[index].amount - excess,
decimalPlaces,
);
next[index] = next[index].copyWith(amount: capped < 0 ? 0 : capped);
}
}
final editedId = next[index].id;
next.removeWhere((p) => p.id != editedId && p.amount <= epsilon);
return next;
}

View file

@ -61,7 +61,7 @@ class QuickSalesPaymentComposer extends StatefulWidget {
class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
static const _uuid = Uuid();
static const _maxLines = 6;
static const _maxLines = 50;
String? _lastBankId;
String? _focusAmountId;
@ -240,8 +240,7 @@ class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
_emit([...widget.payments, tx]);
}
int _indexOfId(String id) =>
widget.payments.indexWhere((p) => p.id == id);
int _indexOfId(String id) => widget.payments.indexWhere((p) => p.id == id);
void _replaceAt(int index, InvoiceTransaction tx) {
if (index < 0 || index >= widget.payments.length) return;
@ -382,8 +381,7 @@ class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
currencyUnit: widget.currencyUnit,
enabled: widget.enabled,
autofocus: false,
onAmountChanged: (v) =>
_onAmountEditedById(widget.payments.first.id, v),
onAmountChanged: (v) => _onAmountEditedById(widget.payments.first.id, v),
);
return LayoutBuilder(

File diff suppressed because it is too large Load diff

View file

@ -23,12 +23,7 @@ void main() {
group('canAddQuickSalesPaymentLine', () {
test('allows a second bank when remaining is already settled', () {
final payments = [
_tx(
id: 'b1',
type: TransactionType.bank,
amount: 800000,
bankId: '10',
),
_tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'),
];
expect(
canAddQuickSalesPaymentLine(
@ -57,7 +52,7 @@ void main() {
});
group('applyQuickSalesPaymentAmount', () {
test('splits a full bank receipt into two same-type receipts', () {
test('keeps payment rows independent when adding a second card', () {
final payments = [
_tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'),
_tx(id: 'b2', type: TransactionType.bank, amount: 0, bankId: '11'),
@ -74,17 +69,14 @@ void main() {
expect(next, hasLength(2));
expect(next[0].id, 'b1');
expect(next[0].type, TransactionType.bank);
expect(next[0].amount, 500000);
expect(next[0].amount, 800000);
expect(next[1].id, 'b2');
expect(next[1].type, TransactionType.bank);
expect(next[1].amount, 300000);
expect(
next.fold<num>(0, (sum, p) => sum + p.amount),
800000,
);
expect(next.fold<num>(0, (sum, p) => sum + p.amount), 1100000);
});
test('reduces cash when a bank line is typed against a settled sale', () {
test('does not reduce cash when a bank line is entered', () {
final payments = [
_tx(
id: 'c1',
@ -104,12 +96,12 @@ void main() {
);
expect(next[0].type, TransactionType.cashRegister);
expect(next[0].amount, 500000);
expect(next[0].amount, 800000);
expect(next[1].type, TransactionType.bank);
expect(next[1].amount, 300000);
});
test('drops other lines that fall to zero so bank can replace cash', () {
test('keeps every row when the total reaches overpayment', () {
final payments = [
_tx(
id: 'c1',
@ -128,12 +120,13 @@ void main() {
decimalPlaces: 0,
);
expect(next, hasLength(1));
expect(next.single.id, 'b1');
expect(next.single.amount, 800000);
expect(next, hasLength(2));
expect(next[0].amount, 800000);
expect(next[1].id, 'b1');
expect(next[1].amount, 800000);
});
test('caps a single line so payments cannot exceed the invoice', () {
test('allows a single payment to exceed the invoice', () {
final payments = [
_tx(
id: 'c1',
@ -151,7 +144,7 @@ void main() {
decimalPlaces: 0,
);
expect(next.single.amount, 800000);
expect(next.single.amount, 900000);
});
});
}