codex/quick-sales-payment-dock #15
18 changed files with 2889 additions and 310 deletions
240
docs/QUICK_SALES_EXTERNAL_REFERENCE.md
Normal file
240
docs/QUICK_SALES_EXTERNAL_REFERENCE.md
Normal 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 شوند.
|
||||||
89
docs/QUICK_SALES_PAYMENT_DOCK.md
Normal file
89
docs/QUICK_SALES_PAYMENT_DOCK.md
Normal 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.
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
|
||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from adapters.db.session import get_db
|
|
||||||
from adapters.db.models.person import PersonType
|
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 (
|
from app.core.permissions import (
|
||||||
has_business_permission_for_business,
|
has_business_permission_for_business,
|
||||||
require_business_access_dep,
|
require_business_access_dep,
|
||||||
|
|
@ -18,9 +18,14 @@ from app.core.permissions import (
|
||||||
)
|
)
|
||||||
from app.core.responses import ApiError, success_response
|
from app.core.responses import ApiError, success_response
|
||||||
from app.services.quick_sales_service import (
|
from app.services.quick_sales_service import (
|
||||||
get_quick_sales_settings,
|
create_quick_sales_payment_button,
|
||||||
update_quick_sales_settings,
|
delete_quick_sales_payment_button,
|
||||||
get_or_create_anonymous_customer,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -110,6 +115,96 @@ def get_anonymous_customer_endpoint(
|
||||||
raise
|
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="فهرست مغایرتهای موجودی فروش سریع")
|
@router.get("/stock-discrepancies", summary="فهرست مغایرتهای موجودی فروش سریع")
|
||||||
def list_stock_discrepancies_endpoint(
|
def list_stock_discrepancies_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ from .warehouse_document import WarehouseDocument # noqa: F401
|
||||||
from .warehouse_document_line import WarehouseDocumentLine # noqa: F401
|
from .warehouse_document_line import WarehouseDocumentLine # noqa: F401
|
||||||
from .goods_expense_income import GoodsExpenseIncomeDocument, GoodsExpenseIncomeLine # noqa: F401
|
from .goods_expense_income import GoodsExpenseIncomeDocument, GoodsExpenseIncomeLine # noqa: F401
|
||||||
from .inventory_discrepancy import InventoryDiscrepancyCase, InventoryDiscrepancyLine # 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 .hscript_report import HScriptReport, HScriptReportVersion, HScriptReportRun, HScriptReportSchedule # noqa: F401
|
||||||
from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401
|
from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401
|
||||||
from .ping_pong_score import PingPongScore # noqa: F401
|
from .ping_pong_score import PingPongScore # noqa: F401
|
||||||
|
|
|
||||||
80
hesabixAPI/adapters/db/models/quick_sales_payment_button.py
Normal file
80
hesabixAPI/adapters/db/models/quick_sales_payment_button.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -184,6 +184,7 @@ ALLOWED_INVOICE_PROFIT_OVERHEAD_TYPES = {"none", "production_overhead", "all_ove
|
||||||
# کلیدهای لینک اسناد جانبی روی extra_info.links فاکتور
|
# کلیدهای لینک اسناد جانبی روی extra_info.links فاکتور
|
||||||
INVOICE_LINK_RECEIPT_PAYMENT_IDS = "receipt_payment_document_ids"
|
INVOICE_LINK_RECEIPT_PAYMENT_IDS = "receipt_payment_document_ids"
|
||||||
INVOICE_LINK_EXPENSE_INCOME_IDS = "expense_income_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:
|
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 = dict(extra.get("links") or {})
|
||||||
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
|
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
|
||||||
links.pop(INVOICE_LINK_EXPENSE_INCOME_IDS, None)
|
links.pop(INVOICE_LINK_EXPENSE_INCOME_IDS, None)
|
||||||
|
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
|
||||||
extra["links"] = links
|
extra["links"] = links
|
||||||
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
||||||
flag_modified(document, "extra_info")
|
flag_modified(document, "extra_info")
|
||||||
|
|
@ -202,6 +204,7 @@ def _delete_invoice_linked_side_documents(
|
||||||
*,
|
*,
|
||||||
receipt_payment_ids: List[Any],
|
receipt_payment_ids: List[Any],
|
||||||
expense_income_ids: List[Any],
|
expense_income_ids: List[Any],
|
||||||
|
change_payment_id: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
حذف اسناد دریافت/پرداخت و هزینه/درآمد پیوندخورده به فاکتور (بدون commit؛ در همان تراکنش caller).
|
حذف اسناد دریافت/پرداخت و هزینه/درآمد پیوندخورده به فاکتور (بدون commit؛ در همان تراکنش caller).
|
||||||
|
|
@ -213,6 +216,10 @@ def _delete_invoice_linked_side_documents(
|
||||||
if rid is None:
|
if rid is None:
|
||||||
continue
|
continue
|
||||||
delete_receipt_payment(db, int(rid), commit=False)
|
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:
|
if expense_income_ids:
|
||||||
from app.services.expense_income_service import delete_expense_income
|
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()
|
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"):
|
if ttype not in ("bank", "cash_register", "petty_cash", "check"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -2722,6 +2761,130 @@ def _create_receipt_payment_documents_for_invoice_payments(
|
||||||
return payment_docs
|
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(
|
def _sum_receipt_payment_amounts_for_invoice(
|
||||||
db: Session,
|
db: Session,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
|
|
@ -2920,7 +3083,11 @@ def _clear_invoice_linked_receipt_payments(
|
||||||
delete_receipt_payment(db, int(rp_id), commit=False)
|
delete_receipt_payment(db, int(rp_id), commit=False)
|
||||||
extra = dict(document.extra_info) if document.extra_info else {}
|
extra = dict(document.extra_info) if document.extra_info else {}
|
||||||
links = dict(extra.get("links") or {})
|
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_RECEIPT_PAYMENT_IDS, None)
|
||||||
|
links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None)
|
||||||
extra["links"] = links
|
extra["links"] = links
|
||||||
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
||||||
flag_modified(document, "extra_info")
|
flag_modified(document, "extra_info")
|
||||||
|
|
@ -5608,12 +5775,39 @@ def create_invoice(
|
||||||
invoice_type=invoice_type,
|
invoice_type=invoice_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment_docs:
|
change_payment_id: int | None = None
|
||||||
logger.info(f"Linking payment_docs to invoice {document.id}: {payment_docs}")
|
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)
|
db.add(document)
|
||||||
extra = dict(document.extra_info) if document.extra_info else {}
|
extra = dict(document.extra_info) if document.extra_info else {}
|
||||||
links = dict(extra.get("links", {}))
|
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
|
extra["links"] = links
|
||||||
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
||||||
flag_modified(document, "extra_info")
|
flag_modified(document, "extra_info")
|
||||||
|
|
@ -5786,6 +5980,7 @@ def update_invoice(
|
||||||
_old_is_proforma = bool(document.is_proforma)
|
_old_is_proforma = bool(document.is_proforma)
|
||||||
_old_receipt_payment_ids = list(_pre_links_wh.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or [])
|
_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_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
|
# Only editable in current fiscal year
|
||||||
fiscal_year = (
|
fiscal_year = (
|
||||||
|
|
@ -5870,6 +6065,7 @@ def update_invoice(
|
||||||
db,
|
db,
|
||||||
receipt_payment_ids=_old_receipt_payment_ids,
|
receipt_payment_ids=_old_receipt_payment_ids,
|
||||||
expense_income_ids=_old_expense_income_ids,
|
expense_income_ids=_old_expense_income_ids,
|
||||||
|
change_payment_id=_old_change_payment_id,
|
||||||
)
|
)
|
||||||
if document.is_proforma:
|
if document.is_proforma:
|
||||||
_strip_invoice_side_document_links_from_document(document)
|
_strip_invoice_side_document_links_from_document(document)
|
||||||
|
|
@ -6649,6 +6845,15 @@ def update_invoice(
|
||||||
# هرگاه payments در payload باشد، اسناد دریافت/پرداخت قبلی حذف میشوند؛ خطای حذف باید به کاربر برگردد
|
# هرگاه payments در payload باشد، اسناد دریافت/پرداخت قبلی حذف میشوند؛ خطای حذف باید به کاربر برگردد
|
||||||
for old_rp_id in old_receipt_payment_ids:
|
for old_rp_id in old_receipt_payment_ids:
|
||||||
delete_receipt_payment(db, int(old_rp_id), commit=False)
|
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:
|
if not payments:
|
||||||
extra = dict(document.extra_info) if document.extra_info else {}
|
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)
|
document.extra_info = _normalize_document_extra_info_for_storage(extra)
|
||||||
flag_modified(document, "extra_info")
|
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:
|
try:
|
||||||
_remove_old_invoice_warehouse_documents(
|
_remove_old_invoice_warehouse_documents(
|
||||||
db,
|
db,
|
||||||
|
|
@ -7029,7 +7259,10 @@ def delete_invoice(db: Session, document_id: int, *, commit: bool = True) -> boo
|
||||||
raise # خطا را propagate کن تا rollback شود
|
raise # خطا را propagate کن تا rollback شود
|
||||||
|
|
||||||
# 5) حذف خودکار همه اسناد دریافت/پرداخت مرتبط (بدون توجه به مبلغ) - بدون commit
|
# 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}")
|
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:
|
if receipt_payment_document_ids:
|
||||||
|
|
@ -7270,10 +7503,11 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
extra_info = document.extra_info or {}
|
extra_info = document.extra_info or {}
|
||||||
links = extra_info.get('links', {})
|
links = extra_info.get("links", {})
|
||||||
receipt_payment_ids = links.get('receipt_payment_document_ids', [])
|
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:
|
|
||||||
|
if not receipt_payment_ids and change_payment_id is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# بررسی وجود واقعی هر سند
|
# بررسی وجود واقعی هر سند
|
||||||
|
|
@ -7284,7 +7518,8 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
|
||||||
# بررسی وجود سند در دیتابیس
|
# بررسی وجود سند در دیتابیس
|
||||||
doc = db.query(Document).filter(
|
doc = db.query(Document).filter(
|
||||||
Document.id == doc_id_int,
|
Document.id == doc_id_int,
|
||||||
Document.document_type.in_(['receipt', 'payment']),
|
Document.business_id == document.business_id,
|
||||||
|
Document.document_type.in_(["receipt", "payment"]),
|
||||||
).first()
|
).first()
|
||||||
if doc:
|
if doc:
|
||||||
valid_ids.append(doc_id_int)
|
valid_ids.append(doc_id_int)
|
||||||
|
|
@ -7292,15 +7527,43 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool
|
||||||
# شناسه نامعتبر، رد میشود
|
# شناسه نامعتبر، رد میشود
|
||||||
continue
|
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
|
return False
|
||||||
|
|
||||||
# بهروزرسانی extra_info با لینکهای معتبر
|
# بهروزرسانی extra_info با لینکهای معتبر
|
||||||
extra_info = dict(extra_info)
|
extra_info = dict(extra_info)
|
||||||
links = dict(links)
|
links = dict(links)
|
||||||
links['receipt_payment_document_ids'] = valid_ids
|
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = valid_ids
|
||||||
extra_info['links'] = links
|
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)
|
document.extra_info = _normalize_document_extra_info_for_storage(extra_info)
|
||||||
flag_modified(document, "extra_info")
|
flag_modified(document, "extra_info")
|
||||||
|
|
@ -10098,7 +10361,7 @@ def _get_receipt_payment_ids_linked_to_invoice(
|
||||||
Integer,
|
Integer,
|
||||||
)
|
)
|
||||||
rows = (
|
rows = (
|
||||||
db.query(DocumentLine.document_id)
|
db.query(DocumentLine.document_id, Document.extra_info)
|
||||||
.join(Document, DocumentLine.document_id == Document.id)
|
.join(Document, DocumentLine.document_id == Document.id)
|
||||||
.filter(
|
.filter(
|
||||||
Document.business_id == business_id,
|
Document.business_id == business_id,
|
||||||
|
|
@ -10110,6 +10373,9 @@ def _get_receipt_payment_ids_linked_to_invoice(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for row in rows:
|
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]))
|
ids.add(int(row[0]))
|
||||||
return list(ids)
|
return list(ids)
|
||||||
|
|
||||||
|
|
@ -10297,6 +10563,12 @@ def batch_calculate_invoices_remaining(
|
||||||
link_doc_ids.add(int(doc_id))
|
link_doc_ids.add(int(doc_id))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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)
|
item_lines_by_doc: Dict[int, List[InvoiceItemLine]] = defaultdict(list)
|
||||||
if need_item_lines:
|
if need_item_lines:
|
||||||
|
|
@ -10383,7 +10655,23 @@ def batch_calculate_invoices_remaining(
|
||||||
if not rp_doc:
|
if not rp_doc:
|
||||||
continue
|
continue
|
||||||
processed_doc_ids.add(doc_id_int)
|
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
|
remaining = total_amount - total_paid
|
||||||
results[inv_id] = {
|
results[inv_id] = {
|
||||||
|
|
@ -10512,7 +10800,32 @@ def calculate_invoice_remaining(
|
||||||
if doc.id in processed_doc_ids:
|
if doc.id in processed_doc_ids:
|
||||||
continue
|
continue
|
||||||
processed_doc_ids.add(doc.id)
|
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
|
remaining = total_amount - total_paid
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
import re
|
||||||
from sqlalchemy import and_, func
|
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.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.core.responses import ApiError
|
||||||
from app.services.person_service import create_person
|
from app.services.person_service import create_person
|
||||||
|
|
||||||
|
|
@ -21,6 +26,417 @@ DEFAULT_SHARE_EXPIRY_HOURS = 168
|
||||||
MIN_SHARE_EXPIRY_HOURS = 1
|
MIN_SHARE_EXPIRY_HOURS = 1
|
||||||
MAX_SHARE_EXPIRY_HOURS = 720
|
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]:
|
def normalize_default_share_channels(value: Any) -> List[str]:
|
||||||
"""نرمالسازی لیست کانالهای اشتراکگذاری."""
|
"""نرمالسازی لیست کانالهای اشتراکگذاری."""
|
||||||
|
|
|
||||||
|
|
@ -828,8 +828,9 @@ def create_receipt_payment(
|
||||||
logger.info(f"انتخاب حساب چک با کد: {account_code}")
|
logger.info(f"انتخاب حساب چک با کد: {account_code}")
|
||||||
account = _get_fixed_account_by_code(db, account_code)
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
elif normalized_transaction_type == "person":
|
elif normalized_transaction_type == "person":
|
||||||
# برای شخص، از حساب شخص عمومی استفاده کن
|
# دریافت نزد شخص یعنی شخص وجه کسبوکار را نگه میدارد و به کسبوکار بدهکار است.
|
||||||
account_code = "20201" # حسابهای پرداختنی
|
# در پرداخت به شخص، حساب پرداختنی شخص استفاده میشود.
|
||||||
|
account_code = "10401" if is_receipt else "20201"
|
||||||
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
||||||
account = _get_fixed_account_by_code(db, account_code)
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
elif normalized_transaction_type == "wallet":
|
elif normalized_transaction_type == "wallet":
|
||||||
|
|
@ -2152,7 +2153,10 @@ def update_receipt_payment(
|
||||||
elif normalized_transaction_type == "check":
|
elif normalized_transaction_type == "check":
|
||||||
account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202")
|
account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202")
|
||||||
elif normalized_transaction_type == "person":
|
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"):
|
elif account_line.get("account_id"):
|
||||||
account = db.query(Account).filter(
|
account = db.query(Account).filter(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -2832,4 +2836,3 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
"created_at": document.created_at.isoformat(),
|
"created_at": document.created_at.isoformat(),
|
||||||
"updated_at": document.updated_at.isoformat(),
|
"updated_at": document.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
30
hesabixAPI/tests/test_quick_sales_change.py
Normal file
30
hesabixAPI/tests/test_quick_sales_change.py
Normal 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
|
||||||
|
|
@ -14,10 +14,11 @@ from app.core.responses import ApiError
|
||||||
from app.services.quick_sales_service import (
|
from app.services.quick_sales_service import (
|
||||||
DEFAULT_SHARE_CHANNELS,
|
DEFAULT_SHARE_CHANNELS,
|
||||||
DEFAULT_SHARE_EXPIRY_HOURS,
|
DEFAULT_SHARE_EXPIRY_HOURS,
|
||||||
|
_normalize_payment_button_shortcut,
|
||||||
|
_share_settings_from_obj,
|
||||||
|
get_quick_sales_settings,
|
||||||
normalize_default_share_channels,
|
normalize_default_share_channels,
|
||||||
normalize_share_expiry_hours,
|
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:
|
with pytest.raises(ApiError) as exc:
|
||||||
_validate_share_gateway_id(db, 1, 99)
|
_validate_share_gateway_id(db, 1, 99)
|
||||||
assert exc.value.detail["error"]["code"] == "GATEWAY_NOT_FOUND"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import '../../models/quick_sales_parked_sale.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/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';
|
||||||
|
|
@ -158,7 +159,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
List<InvoiceTransaction> _payments = [];
|
List<InvoiceTransaction> _payments = [];
|
||||||
String? _selectedCashRegisterId;
|
String? _selectedCashRegisterId;
|
||||||
bool _autoCreatePaymentDocument = true;
|
bool _autoCreatePaymentDocument = true;
|
||||||
bool _cashFollowsTotal = true;
|
bool _cashFollowsTotal = false;
|
||||||
bool _shareRemainingEnabled = false;
|
bool _shareRemainingEnabled = false;
|
||||||
|
|
||||||
// اشتراکگذاری (وقتی سند دریافت ثبت نمیشود)
|
// اشتراکگذاری (وقتی سند دریافت ثبت نمیشود)
|
||||||
|
|
@ -698,7 +699,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_selectedCustomer = _anonymousCustomer;
|
_selectedCustomer = _anonymousCustomer;
|
||||||
_cartItems = [];
|
_cartItems = [];
|
||||||
_payments = [];
|
_payments = [];
|
||||||
_cashFollowsTotal = true;
|
_cashFollowsTotal = false;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_checkoutDiscountExpanded = false;
|
_checkoutDiscountExpanded = false;
|
||||||
_selectedCashRegisterId = _settingsDefaultCashRegisterId;
|
_selectedCashRegisterId = _settingsDefaultCashRegisterId;
|
||||||
|
|
@ -1042,14 +1043,15 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_autoCreatePaymentDocument = value;
|
_autoCreatePaymentDocument = value;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_payments = [];
|
_payments = [];
|
||||||
_cashFollowsTotal = true;
|
_cashFollowsTotal = false;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
_syncShareChannelDefaults();
|
_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 {
|
num get _paymentEpsilon {
|
||||||
if (_invoiceCurrencyDecimalPlaces <= 0) return 0.5;
|
if (_invoiceCurrencyDecimalPlaces <= 0) return 0.5;
|
||||||
|
|
@ -1071,6 +1073,8 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
_autoCreatePaymentDocument &&
|
_autoCreatePaymentDocument &&
|
||||||
_paidAmount > _totalAmount + _paymentEpsilon;
|
_paidAmount > _totalAmount + _paymentEpsilon;
|
||||||
|
|
||||||
|
num get _changeAmount => _isOverpaid ? _paidAmount - _totalAmount : 0;
|
||||||
|
|
||||||
bool get _isAnonymousSelected {
|
bool get _isAnonymousSelected {
|
||||||
if (_selectedCustomer == null || _anonymousCustomer == null) {
|
if (_selectedCustomer == null || _anonymousCustomer == null) {
|
||||||
return _selectedCustomer == null;
|
return _selectedCustomer == null;
|
||||||
|
|
@ -1139,6 +1143,11 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
if (id != null) map['check_id'] = id;
|
if (id != null) map['check_id'] = id;
|
||||||
if (p.checkNumber != null) map['check_number'] = p.checkNumber;
|
if (p.checkNumber != null) map['check_number'] = p.checkNumber;
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1149,7 +1158,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
|
|
||||||
String? _validatePayments(AppLocalizations t) {
|
String? _validatePayments(AppLocalizations t) {
|
||||||
if (!_autoCreatePaymentDocument) return null;
|
if (!_autoCreatePaymentDocument) return null;
|
||||||
if (_isOverpaid) return t.quickSalesPayOverpaidError;
|
|
||||||
for (final p in _payments) {
|
for (final p in _payments) {
|
||||||
if (p.amount <= 0) continue;
|
if (p.amount <= 0) continue;
|
||||||
switch (p.type) {
|
switch (p.type) {
|
||||||
|
|
@ -1168,6 +1176,11 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
return t.quickSalesPaySelectCheck;
|
return t.quickSalesPaySelectCheck;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case TransactionType.person:
|
||||||
|
if (int.tryParse(p.personId ?? '') == null) {
|
||||||
|
return t.quickSalesPayNeedDestination;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return t.quickSalesPayNeedDestination;
|
return t.quickSalesPayNeedDestination;
|
||||||
}
|
}
|
||||||
|
|
@ -2470,6 +2483,8 @@ 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,
|
||||||
}) async {
|
}) async {
|
||||||
// اعتبارسنجی سبد خرید
|
// اعتبارسنجی سبد خرید
|
||||||
if (_cartItems.isEmpty) {
|
if (_cartItems.isEmpty) {
|
||||||
|
|
@ -2507,6 +2522,64 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
return;
|
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 (_shouldShareAfterSave) {
|
||||||
if (_shareOnlinePaymentEnabled) {
|
if (_shareOnlinePaymentEnabled) {
|
||||||
if (_shareGateways.isEmpty) {
|
if (_shareGateways.isEmpty) {
|
||||||
|
|
@ -2643,6 +2716,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
'tax': _totalTax,
|
'tax': _totalTax,
|
||||||
'net': _totalAmount,
|
'net': _totalAmount,
|
||||||
},
|
},
|
||||||
|
if (_isOverpaid)
|
||||||
|
'quick_sale_change': {
|
||||||
|
'amount': _changeAmount,
|
||||||
|
'cash_register_id': int.tryParse(_selectedCashRegisterId ?? ''),
|
||||||
|
'confirmed_returned': changeConfirmed,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
final gvSave = _parsedGlobalDiscountValue;
|
final gvSave = _parsedGlobalDiscountValue;
|
||||||
|
|
@ -2683,7 +2762,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
setState(() {
|
setState(() {
|
||||||
_cartItems.clear();
|
_cartItems.clear();
|
||||||
_payments = [];
|
_payments = [];
|
||||||
_cashFollowsTotal = true;
|
_cashFollowsTotal = false;
|
||||||
_shareRemainingEnabled = false;
|
_shareRemainingEnabled = false;
|
||||||
_checkoutDiscountExpanded = false;
|
_checkoutDiscountExpanded = false;
|
||||||
_productStocks.clear();
|
_productStocks.clear();
|
||||||
|
|
@ -2773,7 +2852,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
if (parkAfterFailure) {
|
if (parkAfterFailure) {
|
||||||
_startNewSale();
|
_startNewSale();
|
||||||
} else if (retryAfterCorrection || retryOverride != null) {
|
} 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(
|
return _withParkedSalesBar(
|
||||||
Row(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
// ستون چپ: سبد خرید
|
Expanded(
|
||||||
Expanded(flex: 7, child: cartColumn),
|
child: Row(
|
||||||
// ستون راست: خلاصه و پرداخت
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
_buildDesktopCheckoutPanel(cs),
|
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,
|
compact: isCompact,
|
||||||
|
|
@ -5089,28 +5201,18 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
return Container(
|
return Container(
|
||||||
width: 384,
|
width: 384,
|
||||||
color: cs.surfaceContainerHighest,
|
color: cs.surfaceContainerHighest,
|
||||||
child: Column(
|
child: Scrollbar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
thumbVisibility: true,
|
||||||
children: [
|
child: SingleChildScrollView(
|
||||||
Expanded(
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
child: Scrollbar(
|
child: _buildCheckoutScrollBody(
|
||||||
thumbVisibility: true,
|
cs,
|
||||||
child: SingleChildScrollView(
|
includeCustomer: true,
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
includeDocumentMeta: true,
|
||||||
child: _buildCheckoutScrollBody(
|
includePayment: false,
|
||||||
cs,
|
includeTotal: false,
|
||||||
includeCustomer: true,
|
|
||||||
includeDocumentMeta: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Material(
|
),
|
||||||
elevation: 2,
|
|
||||||
color: cs.surface,
|
|
||||||
child: _buildCheckoutButtons(cs),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5119,6 +5221,8 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
ColorScheme cs, {
|
ColorScheme cs, {
|
||||||
required bool includeCustomer,
|
required bool includeCustomer,
|
||||||
required bool includeDocumentMeta,
|
required bool includeDocumentMeta,
|
||||||
|
bool includePayment = true,
|
||||||
|
bool includeTotal = true,
|
||||||
}) {
|
}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -5142,13 +5246,50 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
_buildCheckoutTotalHero(cs),
|
if (includeTotal) ...[
|
||||||
const SizedBox(height: 4),
|
_buildCheckoutTotalHero(cs),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
_buildCheckoutDiscountBlock(cs),
|
_buildCheckoutDiscountBlock(cs),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildPaymentSection(cs),
|
if (includePayment) ...[
|
||||||
const SizedBox(height: 4),
|
_buildPaymentSection(cs),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
_buildCheckoutDetailsTile(cs, includeDocumentMeta: includeDocumentMeta),
|
_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(
|
Widget _buildSummaryRow(
|
||||||
String label,
|
String label,
|
||||||
String value, {
|
String value, {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import '../core/api_client.dart';
|
import '../core/api_client.dart';
|
||||||
|
import '../models/quick_sales_payment_button.dart';
|
||||||
|
|
||||||
class QuickSalesService {
|
class QuickSalesService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
|
@ -35,6 +36,80 @@ class QuickSalesService {
|
||||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
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({
|
Future<Map<String, dynamic>> createStockCorrection({
|
||||||
required int businessId,
|
required int businessId,
|
||||||
required Map<String, dynamic> payload,
|
required Map<String, dynamic> payload,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ bool canAddQuickSalesPaymentLine({
|
||||||
required List<InvoiceTransaction> payments,
|
required List<InvoiceTransaction> payments,
|
||||||
required bool enabled,
|
required bool enabled,
|
||||||
required num epsilon,
|
required num epsilon,
|
||||||
int maxLines = 6,
|
int maxLines = 50,
|
||||||
}) {
|
}) {
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
if (payments.length >= maxLines) return false;
|
if (payments.length >= maxLines) return false;
|
||||||
|
|
@ -37,11 +37,10 @@ bool canAddQuickSalesPaymentLine({
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// اعمال مبلغ روی یک ردیف و حفظ تراز با فاکتور.
|
/// اعمال مبلغ روی یک ردیف پرداخت.
|
||||||
///
|
///
|
||||||
/// اگر جمع از مبلغ فاکتور بیشتر شود، از ردیفهای دیگر (از آخر)
|
/// پرداختها مستقل از یکدیگر نگه داشته میشوند. جمع بیشتر از مبلغ
|
||||||
/// کم میشود تا دفاتر از حد فاکتور رد نشوند. ردیفهای دیگر که صفر
|
/// فاکتور مجاز است و در فروش سریع به صورت وجه برگشتی ثبت میشود.
|
||||||
/// شدند حذف میشوند. ردیف در حال ویرایش حتی با مبلغ صفر میماند.
|
|
||||||
List<InvoiceTransaction> applyQuickSalesPaymentAmount({
|
List<InvoiceTransaction> applyQuickSalesPaymentAmount({
|
||||||
required List<InvoiceTransaction> payments,
|
required List<InvoiceTransaction> payments,
|
||||||
required int index,
|
required int index,
|
||||||
|
|
@ -53,35 +52,8 @@ List<InvoiceTransaction> applyQuickSalesPaymentAmount({
|
||||||
return List<InvoiceTransaction>.from(payments);
|
return List<InvoiceTransaction>.from(payments);
|
||||||
}
|
}
|
||||||
|
|
||||||
final epsilon = quickSalesPaymentEpsilon(decimalPlaces);
|
|
||||||
final next = List<InvoiceTransaction>.from(payments);
|
final next = List<InvoiceTransaction>.from(payments);
|
||||||
final safe = amount < 0 ? 0 : roundQuickSalesMoney(amount, decimalPlaces);
|
final safe = amount < 0 ? 0 : roundQuickSalesMoney(amount, decimalPlaces);
|
||||||
next[index] = next[index].copyWith(amount: safe);
|
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class QuickSalesPaymentComposer extends StatefulWidget {
|
||||||
|
|
||||||
class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
|
class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
static const _maxLines = 6;
|
static const _maxLines = 50;
|
||||||
|
|
||||||
String? _lastBankId;
|
String? _lastBankId;
|
||||||
String? _focusAmountId;
|
String? _focusAmountId;
|
||||||
|
|
@ -240,8 +240,7 @@ class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
|
||||||
_emit([...widget.payments, tx]);
|
_emit([...widget.payments, tx]);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _indexOfId(String id) =>
|
int _indexOfId(String id) => widget.payments.indexWhere((p) => p.id == id);
|
||||||
widget.payments.indexWhere((p) => p.id == id);
|
|
||||||
|
|
||||||
void _replaceAt(int index, InvoiceTransaction tx) {
|
void _replaceAt(int index, InvoiceTransaction tx) {
|
||||||
if (index < 0 || index >= widget.payments.length) return;
|
if (index < 0 || index >= widget.payments.length) return;
|
||||||
|
|
@ -382,8 +381,7 @@ class _QuickSalesPaymentComposerState extends State<QuickSalesPaymentComposer> {
|
||||||
currencyUnit: widget.currencyUnit,
|
currencyUnit: widget.currencyUnit,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
onAmountChanged: (v) =>
|
onAmountChanged: (v) => _onAmountEditedById(widget.payments.first.id, v),
|
||||||
_onAmountEditedById(widget.payments.first.id, v),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -23,12 +23,7 @@ void main() {
|
||||||
group('canAddQuickSalesPaymentLine', () {
|
group('canAddQuickSalesPaymentLine', () {
|
||||||
test('allows a second bank when remaining is already settled', () {
|
test('allows a second bank when remaining is already settled', () {
|
||||||
final payments = [
|
final payments = [
|
||||||
_tx(
|
_tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'),
|
||||||
id: 'b1',
|
|
||||||
type: TransactionType.bank,
|
|
||||||
amount: 800000,
|
|
||||||
bankId: '10',
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
canAddQuickSalesPaymentLine(
|
canAddQuickSalesPaymentLine(
|
||||||
|
|
@ -57,7 +52,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('applyQuickSalesPaymentAmount', () {
|
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 = [
|
final payments = [
|
||||||
_tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'),
|
_tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'),
|
||||||
_tx(id: 'b2', type: TransactionType.bank, amount: 0, bankId: '11'),
|
_tx(id: 'b2', type: TransactionType.bank, amount: 0, bankId: '11'),
|
||||||
|
|
@ -74,17 +69,14 @@ void main() {
|
||||||
expect(next, hasLength(2));
|
expect(next, hasLength(2));
|
||||||
expect(next[0].id, 'b1');
|
expect(next[0].id, 'b1');
|
||||||
expect(next[0].type, TransactionType.bank);
|
expect(next[0].type, TransactionType.bank);
|
||||||
expect(next[0].amount, 500000);
|
expect(next[0].amount, 800000);
|
||||||
expect(next[1].id, 'b2');
|
expect(next[1].id, 'b2');
|
||||||
expect(next[1].type, TransactionType.bank);
|
expect(next[1].type, TransactionType.bank);
|
||||||
expect(next[1].amount, 300000);
|
expect(next[1].amount, 300000);
|
||||||
expect(
|
expect(next.fold<num>(0, (sum, p) => sum + p.amount), 1100000);
|
||||||
next.fold<num>(0, (sum, p) => sum + p.amount),
|
|
||||||
800000,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = [
|
final payments = [
|
||||||
_tx(
|
_tx(
|
||||||
id: 'c1',
|
id: 'c1',
|
||||||
|
|
@ -104,12 +96,12 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(next[0].type, TransactionType.cashRegister);
|
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].type, TransactionType.bank);
|
||||||
expect(next[1].amount, 300000);
|
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 = [
|
final payments = [
|
||||||
_tx(
|
_tx(
|
||||||
id: 'c1',
|
id: 'c1',
|
||||||
|
|
@ -128,12 +120,13 @@ void main() {
|
||||||
decimalPlaces: 0,
|
decimalPlaces: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(next, hasLength(1));
|
expect(next, hasLength(2));
|
||||||
expect(next.single.id, 'b1');
|
expect(next[0].amount, 800000);
|
||||||
expect(next.single.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 = [
|
final payments = [
|
||||||
_tx(
|
_tx(
|
||||||
id: 'c1',
|
id: 'c1',
|
||||||
|
|
@ -151,7 +144,7 @@ void main() {
|
||||||
decimalPlaces: 0,
|
decimalPlaces: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(next.single.amount, 800000);
|
expect(next.single.amount, 900000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue