From 7442adf47368333e1afc8fbbe8805bad2dbb3f41 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 13 Oct 2026 17:07:11 +0330 Subject: [PATCH 1/3] docs: record external quick-sales reference --- docs/QUICK_SALES_EXTERNAL_REFERENCE.md | 240 +++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/QUICK_SALES_EXTERNAL_REFERENCE.md diff --git a/docs/QUICK_SALES_EXTERNAL_REFERENCE.md b/docs/QUICK_SALES_EXTERNAL_REFERENCE.md new file mode 100644 index 00000000..0fd07ca0 --- /dev/null +++ b/docs/QUICK_SALES_EXTERNAL_REFERENCE.md @@ -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 شوند. -- 2.43.0 From 19d5746772f50843ce64652f194f30196e2e5bc9 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 13 Oct 2026 19:25:38 +0330 Subject: [PATCH 2/3] feat(quick-sales): add configurable payment dock --- docs/QUICK_SALES_PAYMENT_DOCK.md | 89 ++ hesabixAPI/adapters/api/v1/quick_sales.py | 109 +- hesabixAPI/adapters/db/models/__init__.py | 1 + .../db/models/quick_sales_payment_button.py | 80 ++ hesabixAPI/app/services/invoice_service.py | 343 ++++- .../app/services/quick_sales_service.py | 428 ++++++- .../app/services/receipt_payment_service.py | 11 +- ...1013_000001_quick_sales_payment_buttons.py | 89 ++ hesabixAPI/tests/test_quick_sales_change.py | 30 + hesabixAPI/tests/test_quick_sales_service.py | 19 +- .../models/quick_sales_payment_button.dart | 62 + .../lib/pages/business/quick_sales_page.dart | 390 +++--- .../lib/services/quick_sales_service.dart | 75 ++ .../utils/quick_sales_payment_balance.dart | 36 +- .../quick_sales_payment_composer.dart | 8 +- .../quick_sales/quick_sales_payment_dock.dart | 1132 +++++++++++++++++ .../quick_sales_payment_balance_test.dart | 33 +- 17 files changed, 2625 insertions(+), 310 deletions(-) create mode 100644 docs/QUICK_SALES_PAYMENT_DOCK.md create mode 100644 hesabixAPI/adapters/db/models/quick_sales_payment_button.py create mode 100644 hesabixAPI/migrations/versions/20261013_000001_quick_sales_payment_buttons.py create mode 100644 hesabixAPI/tests/test_quick_sales_change.py create mode 100644 hesabixUI/hesabix_ui/lib/models/quick_sales_payment_button.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart diff --git a/docs/QUICK_SALES_PAYMENT_DOCK.md b/docs/QUICK_SALES_PAYMENT_DOCK.md new file mode 100644 index 00000000..6c3f26ea --- /dev/null +++ b/docs/QUICK_SALES_PAYMENT_DOCK.md @@ -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. diff --git a/hesabixAPI/adapters/api/v1/quick_sales.py b/hesabixAPI/adapters/api/v1/quick_sales.py index 482abe03..e428cd25 100755 --- a/hesabixAPI/adapters/api/v1/quick_sales.py +++ b/hesabixAPI/adapters/api/v1/quick_sales.py @@ -1,16 +1,16 @@ from __future__ import annotations import json -from datetime import date, datetime import logging -from typing import Dict, Any +from datetime import date, datetime +from typing import Any, Dict -from fastapi import APIRouter, Depends, Request, Body +from fastapi import APIRouter, Body, Depends, Request from sqlalchemy.orm import Session -from adapters.db.session import get_db from adapters.db.models.person import PersonType -from app.core.auth_dependency import get_current_user, AuthContext +from adapters.db.session import get_db +from app.core.auth_dependency import AuthContext, get_current_user from app.core.permissions import ( has_business_permission_for_business, require_business_access_dep, @@ -18,9 +18,14 @@ from app.core.permissions import ( ) from app.core.responses import ApiError, success_response from app.services.quick_sales_service import ( - get_quick_sales_settings, - update_quick_sales_settings, + create_quick_sales_payment_button, + delete_quick_sales_payment_button, get_or_create_anonymous_customer, + get_quick_sales_settings, + list_quick_sales_payment_buttons, + reorder_quick_sales_payment_buttons, + update_quick_sales_payment_button, + update_quick_sales_settings, ) logger = logging.getLogger(__name__) @@ -110,6 +115,96 @@ def get_anonymous_customer_endpoint( raise +@router.get("/payment-buttons", summary="دکمه‌های پرداخت فروش سریع") +def list_payment_buttons_endpoint( + request: Request, + business_id: int, + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + data = list_quick_sales_payment_buttons(db, business_id, ctx.get_user_id()) + return success_response(data, request) + + +@router.post("/payment-buttons", summary="ایجاد دکمه پرداخت فروش سریع") +def create_payment_button_endpoint( + request: Request, + business_id: int, + payload: Dict[str, Any] = Body(...), + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + data = create_quick_sales_payment_button( + db, + business_id=business_id, + user_id=ctx.get_user_id(), + payload=payload, + can_manage_business=_can(ctx, db, business_id, "settings", "business"), + ) + return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTON_CREATED") + + +@router.put("/payment-buttons/reorder", summary="تغییر ترتیب دکمه‌های پرداخت فروش سریع") +def reorder_payment_buttons_endpoint( + request: Request, + business_id: int, + payload: Dict[str, Any] = Body(...), + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + data = reorder_quick_sales_payment_buttons( + db, + business_id=business_id, + user_id=ctx.get_user_id(), + button_ids=list(payload.get("button_ids") or []), + can_manage_business=_can(ctx, db, business_id, "settings", "business"), + ) + return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTONS_REORDERED") + + +@router.put("/payment-buttons/{button_id}", summary="ویرایش دکمه پرداخت فروش سریع") +def update_payment_button_endpoint( + request: Request, + business_id: int, + button_id: int, + payload: Dict[str, Any] = Body(...), + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + data = update_quick_sales_payment_button( + db, + business_id=business_id, + button_id=button_id, + user_id=ctx.get_user_id(), + payload=payload, + can_manage_business=_can(ctx, db, business_id, "settings", "business"), + ) + return success_response(data, request, message="QUICK_SALES_PAYMENT_BUTTON_UPDATED") + + +@router.delete("/payment-buttons/{button_id}", summary="حذف دکمه پرداخت فروش سریع") +def delete_payment_button_endpoint( + request: Request, + business_id: int, + button_id: int, + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + delete_quick_sales_payment_button( + db, + business_id=business_id, + button_id=button_id, + user_id=ctx.get_user_id(), + can_manage_business=_can(ctx, db, business_id, "settings", "business"), + ) + return success_response({"id": button_id}, request, message="QUICK_SALES_PAYMENT_BUTTON_DELETED") + + @router.get("/stock-discrepancies", summary="فهرست مغایرت‌های موجودی فروش سریع") def list_stock_discrepancies_endpoint( request: Request, diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 40643503..873e65c8 100755 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -89,6 +89,7 @@ from .warehouse_document import WarehouseDocument # noqa: F401 from .warehouse_document_line import WarehouseDocumentLine # noqa: F401 from .goods_expense_income import GoodsExpenseIncomeDocument, GoodsExpenseIncomeLine # noqa: F401 from .inventory_discrepancy import InventoryDiscrepancyCase, InventoryDiscrepancyLine # noqa: F401 +from .quick_sales_payment_button import QuickSalesPaymentButton # noqa: F401 from .hscript_report import HScriptReport, HScriptReportVersion, HScriptReportRun, HScriptReportSchedule # noqa: F401 from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401 from .ping_pong_score import PingPongScore # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/quick_sales_payment_button.py b/hesabixAPI/adapters/db/models/quick_sales_payment_button.py new file mode 100644 index 00000000..2a98d572 --- /dev/null +++ b/hesabixAPI/adapters/db/models/quick_sales_payment_button.py @@ -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 + ) diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index 6c1a54e2..d2d037cd 100755 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -184,6 +184,7 @@ ALLOWED_INVOICE_PROFIT_OVERHEAD_TYPES = {"none", "production_overhead", "all_ove # کلیدهای لینک اسناد جانبی روی extra_info.links فاکتور INVOICE_LINK_RECEIPT_PAYMENT_IDS = "receipt_payment_document_ids" INVOICE_LINK_EXPENSE_INCOME_IDS = "expense_income_document_ids" +INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID = "quick_sale_change_payment_document_id" def _strip_invoice_side_document_links_from_document(document: Document) -> None: @@ -192,6 +193,7 @@ def _strip_invoice_side_document_links_from_document(document: Document) -> None links = dict(extra.get("links") or {}) links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None) links.pop(INVOICE_LINK_EXPENSE_INCOME_IDS, None) + links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None) extra["links"] = links document.extra_info = _normalize_document_extra_info_for_storage(extra) flag_modified(document, "extra_info") @@ -202,6 +204,7 @@ def _delete_invoice_linked_side_documents( *, receipt_payment_ids: List[Any], expense_income_ids: List[Any], + change_payment_id: Any = None, ) -> None: """ حذف اسناد دریافت/پرداخت و هزینه/درآمد پیوندخورده به فاکتور (بدون commit؛ در همان تراکنش caller). @@ -213,6 +216,10 @@ def _delete_invoice_linked_side_documents( if rid is None: continue delete_receipt_payment(db, int(rid), commit=False) + if change_payment_id is not None: + from app.services.receipt_payment_service import delete_receipt_payment + + delete_receipt_payment(db, int(change_payment_id), commit=False) if expense_income_ids: from app.services.expense_income_service import delete_expense_income @@ -2425,6 +2432,38 @@ def _validate_invoice_payment_item_currency( ) ttype = (payment_item.get("transaction_type") or payment_item.get("type") or "").strip().lower() + if ttype == "person": + ref_id = payment_item.get("person_id") + if not ref_id: + raise ApiError( + "PAYMENT_PERSON_REQUIRED", + "Payment person is required", + http_status=400, + ) + target_person = db.query(Person).filter(Person.id == int(ref_id)).first() + if not target_person: + raise ApiError( + "PAYMENT_ACCOUNT_NOT_FOUND", + "Payment person not found", + http_status=404, + ) + if business_id is not None and int(target_person.business_id) != int(business_id): + raise ApiError( + "PAYMENT_ACCOUNT_BUSINESS_MISMATCH", + "Payment person does not belong to this business", + http_status=400, + ) + invoice_person_id = _person_id_from_header( + {"extra_info": invoice.extra_info or {}} + ) if invoice is not None else None + if invoice_person_id and int(invoice_person_id) == int(ref_id): + raise ApiError( + "PAYMENT_PERSON_SAME_AS_CUSTOMER", + "شخص مقصد پرداخت نمی‌تواند همان مشتری فاکتور باشد", + http_status=400, + ) + return None + if ttype not in ("bank", "cash_register", "petty_cash", "check"): return None @@ -2722,6 +2761,130 @@ def _create_receipt_payment_documents_for_invoice_payments( return payment_docs +def _quick_sale_change_amount(document: Document, payments: List[Dict[str, Any]]) -> Decimal: + """مبلغ اضافه‌دریافت نسبت به خالص فاکتور، به ارز خود فاکتور.""" + total = Decimal(str(((document.extra_info or {}).get("totals") or {}).get("net", 0) or 0)) + paid = Decimal(0) + for item in payments: + raw = item.get("settles_amount") + if raw is None: + raw = item.get("amount", 0) + amount = Decimal(str(raw or 0)) + if amount > 0: + paid += amount + return max(Decimal(0), paid - total) + + +def _create_quick_sale_change_payment( + db: Session, + *, + business_id: int, + user_id: int, + document: Document, + person_id: int, + payments: List[Dict[str, Any]], + change_request: Dict[str, Any] | None, +) -> int | None: + """ثبت وجه برگشتی فروش سریع از صندوق، در همان تراکنش ساخت فاکتور.""" + change_amount = _quick_sale_change_amount(document, payments) + if change_amount <= Decimal("0.01"): + return None + request = dict(change_request) if isinstance(change_request, dict) else {} + if request.get("confirmed_returned") is not True: + raise ApiError( + "QUICK_SALE_CHANGE_CONFIRMATION_REQUIRED", + "باید پرداخت مبلغ قابل برگشت به مشتری تأیید شود", + http_status=400, + ) + requested_amount = Decimal(str(request.get("amount", 0) or 0)) + if abs(requested_amount - change_amount) > Decimal("0.01"): + raise ApiError( + "QUICK_SALE_CHANGE_AMOUNT_MISMATCH", + "مبلغ قابل برگشت با اضافه‌پرداخت فاکتور هم‌خوانی ندارد", + http_status=400, + ) + try: + cash_register_id = int(request.get("cash_register_id")) + except (TypeError, ValueError) as exc: + raise ApiError( + "QUICK_SALE_CHANGE_CASH_REGISTER_REQUIRED", + "برای برگشت وجه انتخاب صندوق نقدی الزامی است", + http_status=400, + ) from exc + register = ( + db.query(CashRegister) + .filter( + CashRegister.id == cash_register_id, + CashRegister.business_id == int(business_id), + CashRegister.is_active == True, # noqa: E712 + ) + .first() + ) + if not register: + raise ApiError( + "QUICK_SALE_CHANGE_CASH_REGISTER_NOT_FOUND", + "صندوق برگشت وجه یافت نشد یا غیرفعال است", + http_status=404, + ) + if int(register.currency_id) != int(document.currency_id): + raise ApiError( + "QUICK_SALE_CHANGE_CURRENCY_MISMATCH", + "ارز صندوق برگشت وجه با ارز فاکتور یکسان نیست", + http_status=400, + ) + from app.services.receipt_payment_service import create_receipt_payment + + rp = create_receipt_payment( + db=db, + business_id=int(business_id), + user_id=int(user_id), + commit=False, + data={ + "document_type": "payment", + "document_date": document.document_date.isoformat(), + "currency_id": int(document.currency_id), + "description": f"برگشت اضافه‌دریافت فروش سریع فاکتور {document.code}", + "person_lines": [{ + "person_id": int(person_id), + "amount": float(change_amount), + "description": f"وجه برگشتی فاکتور {document.code}", + }], + "account_lines": [{ + "transaction_type": "cash_register", + "cash_register_id": cash_register_id, + "cash_register_name": register.name, + "amount": float(change_amount), + "transaction_date": document.document_date.isoformat(), + "description": f"برگشت اضافه‌دریافت فاکتور {document.code}", + }], + "extra_info": { + "source": "quick_sale_change", + "invoice_id": int(document.id), + "person_is_receivable": True, + "gross_received": float( + change_amount + + Decimal( + str( + ((document.extra_info or {}).get("totals") or {}).get( + "net", 0 + ) + or 0 + ) + ) + ), + "change_amount": float(change_amount), + }, + }, + ) + if not isinstance(rp, dict) or not rp.get("id"): + raise ApiError( + "QUICK_SALE_CHANGE_CREATE_FAILED", + "ثبت سند برگشت وجه ناموفق بود", + http_status=500, + ) + return int(rp["id"]) + + def _sum_receipt_payment_amounts_for_invoice( db: Session, business_id: int, @@ -2920,7 +3083,11 @@ def _clear_invoice_linked_receipt_payments( delete_receipt_payment(db, int(rp_id), commit=False) extra = dict(document.extra_info) if document.extra_info else {} links = dict(extra.get("links") or {}) + change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + if change_payment_id is not None: + delete_receipt_payment(db, int(change_payment_id), commit=False) links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None) + links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None) extra["links"] = links document.extra_info = _normalize_document_extra_info_for_storage(extra) flag_modified(document, "extra_info") @@ -5608,12 +5775,39 @@ def create_invoice( invoice_type=invoice_type, ) - if payment_docs: - logger.info(f"Linking payment_docs to invoice {document.id}: {payment_docs}") + change_payment_id: int | None = None + if ( + not document.is_proforma + and is_quick_sale + and invoice_type == INVOICE_SALES + and payments + and isinstance(payments, list) + and person_id + ): + change_payment_id = _create_quick_sale_change_payment( + db, + business_id=business_id, + user_id=user_id, + document=document, + person_id=int(person_id), + payments=payments, + change_request=extra_info_all.get("quick_sale_change"), + ) + + if payment_docs or change_payment_id: + logger.info( + "Linking payment docs to invoice %s: receipts=%s change=%s", + document.id, + payment_docs, + change_payment_id, + ) db.add(document) extra = dict(document.extra_info) if document.extra_info else {} links = dict(extra.get("links", {})) - links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs + if payment_docs: + links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs + if change_payment_id: + links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = change_payment_id extra["links"] = links document.extra_info = _normalize_document_extra_info_for_storage(extra) flag_modified(document, "extra_info") @@ -5786,6 +5980,7 @@ def update_invoice( _old_is_proforma = bool(document.is_proforma) _old_receipt_payment_ids = list(_pre_links_wh.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or []) _old_expense_income_ids = list(_pre_links_wh.get(INVOICE_LINK_EXPENSE_INCOME_IDS) or []) + _old_change_payment_id = _pre_links_wh.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) # Only editable in current fiscal year fiscal_year = ( @@ -5870,6 +6065,7 @@ def update_invoice( db, receipt_payment_ids=_old_receipt_payment_ids, expense_income_ids=_old_expense_income_ids, + change_payment_id=_old_change_payment_id, ) if document.is_proforma: _strip_invoice_side_document_links_from_document(document) @@ -6649,6 +6845,15 @@ def update_invoice( # هرگاه payments در payload باشد، اسناد دریافت/پرداخت قبلی حذف می‌شوند؛ خطای حذف باید به کاربر برگردد for old_rp_id in old_receipt_payment_ids: delete_receipt_payment(db, int(old_rp_id), commit=False) + if _old_change_payment_id is not None: + delete_receipt_payment(db, int(_old_change_payment_id), commit=False) + + extra = dict(document.extra_info) if document.extra_info else {} + links = dict(extra.get("links", {})) + links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None) + extra["links"] = links + document.extra_info = _normalize_document_extra_info_for_storage(extra) + flag_modified(document, "extra_info") if not payments: extra = dict(document.extra_info) if document.extra_info else {} @@ -6682,6 +6887,31 @@ def update_invoice( document.extra_info = _normalize_document_extra_info_for_storage(extra) flag_modified(document, "extra_info") + is_quick_sale_pm = bool((document.extra_info or {}).get("quick_sale", False)) + if payments and inv_type == INVOICE_SALES and is_quick_sale_pm and person_id_pm: + change_amount = _quick_sale_change_amount(document, payments) + if change_amount <= Decimal("0.01"): + extra = dict(document.extra_info) if document.extra_info else {} + extra.pop("quick_sale_change", None) + document.extra_info = _normalize_document_extra_info_for_storage(extra) + flag_modified(document, "extra_info") + change_payment_id = _create_quick_sale_change_payment( + db, + business_id=int(document.business_id), + user_id=user_id, + document=document, + person_id=int(person_id_pm), + payments=payments, + change_request=(document.extra_info or {}).get("quick_sale_change"), + ) + if change_payment_id: + extra = dict(document.extra_info) if document.extra_info else {} + links = dict(extra.get("links", {})) + links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = change_payment_id + extra["links"] = links + document.extra_info = _normalize_document_extra_info_for_storage(extra) + flag_modified(document, "extra_info") + try: _remove_old_invoice_warehouse_documents( db, @@ -7029,7 +7259,10 @@ def delete_invoice(db: Session, document_id: int, *, commit: bool = True) -> boo raise # خطا را propagate کن تا rollback شود # 5) حذف خودکار همه اسناد دریافت/پرداخت مرتبط (بدون توجه به مبلغ) - بدون commit - receipt_payment_document_ids = links.get("receipt_payment_document_ids") or [] + receipt_payment_document_ids = list(links.get("receipt_payment_document_ids") or []) + change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + if change_payment_id is not None: + receipt_payment_document_ids.append(change_payment_id) logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found {len(receipt_payment_document_ids)} receipt/payment document IDs in links: {receipt_payment_document_ids}") if receipt_payment_document_ids: @@ -7270,10 +7503,11 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool """ try: extra_info = document.extra_info or {} - links = extra_info.get('links', {}) - receipt_payment_ids = links.get('receipt_payment_document_ids', []) - - if not receipt_payment_ids: + links = extra_info.get("links", {}) + receipt_payment_ids = links.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS, []) + change_payment_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + + if not receipt_payment_ids and change_payment_id is None: return False # بررسی وجود واقعی هر سند @@ -7284,7 +7518,8 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool # بررسی وجود سند در دیتابیس doc = db.query(Document).filter( Document.id == doc_id_int, - Document.document_type.in_(['receipt', 'payment']), + Document.business_id == document.business_id, + Document.document_type.in_(["receipt", "payment"]), ).first() if doc: valid_ids.append(doc_id_int) @@ -7292,15 +7527,43 @@ def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool # شناسه نامعتبر، رد می‌شود continue + valid_change_payment_id = None + parsed_change_id = None + if change_payment_id is not None: + try: + parsed_change_id = int(change_payment_id) + change_doc = db.query(Document).filter( + Document.id == parsed_change_id, + Document.business_id == document.business_id, + Document.document_type == "payment", + ).first() + if change_doc: + valid_change_payment_id = parsed_change_id + except (ValueError, TypeError): + pass + # اگر همه لینک‌ها معتبر هستند، نیازی به به‌روزرسانی نیست - if len(valid_ids) == len(receipt_payment_ids): + if ( + len(valid_ids) == len(receipt_payment_ids) + and ( + change_payment_id is None + or ( + parsed_change_id is not None + and valid_change_payment_id == parsed_change_id + ) + ) + ): return False # به‌روزرسانی extra_info با لینک‌های معتبر extra_info = dict(extra_info) links = dict(links) - links['receipt_payment_document_ids'] = valid_ids - extra_info['links'] = links + links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = valid_ids + if valid_change_payment_id is None: + links.pop(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID, None) + else: + links[INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID] = valid_change_payment_id + extra_info["links"] = links document.extra_info = _normalize_document_extra_info_for_storage(extra_info) flag_modified(document, "extra_info") @@ -10098,7 +10361,7 @@ def _get_receipt_payment_ids_linked_to_invoice( Integer, ) rows = ( - db.query(DocumentLine.document_id) + db.query(DocumentLine.document_id, Document.extra_info) .join(Document, DocumentLine.document_id == Document.id) .filter( Document.business_id == business_id, @@ -10110,6 +10373,9 @@ def _get_receipt_payment_ids_linked_to_invoice( .all() ) for row in rows: + row_extra = row[1] if isinstance(row[1], dict) else {} + if row_extra.get("source") == "quick_sale_change": + continue ids.add(int(row[0])) return list(ids) @@ -10297,6 +10563,12 @@ def batch_calculate_invoices_remaining( link_doc_ids.add(int(doc_id)) except (ValueError, TypeError): pass + change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + if change_doc_id is not None: + try: + link_doc_ids.add(int(change_doc_id)) + except (ValueError, TypeError): + pass item_lines_by_doc: Dict[int, List[InvoiceItemLine]] = defaultdict(list) if need_item_lines: @@ -10383,7 +10655,23 @@ def batch_calculate_invoices_remaining( if not rp_doc: continue processed_doc_ids.add(doc_id_int) - total_paid += _sum_receipt_doc_payments_for_invoice(rp_doc, inv_id, via_person_line=True) + if (rp_doc.extra_info or {}).get("source") == "quick_sale_change": + total_paid -= _sum_receipt_doc_account_payments(rp_doc) + else: + total_paid += _sum_receipt_doc_payments_for_invoice( + rp_doc, inv_id, via_person_line=True + ) + + change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + try: + parsed_change_doc_id = int(change_doc_id) if change_doc_id is not None else None + except (ValueError, TypeError): + parsed_change_doc_id = None + if parsed_change_doc_id is not None and parsed_change_doc_id not in processed_doc_ids: + change_doc = rp_docs_by_id.get(parsed_change_doc_id) + if change_doc: + processed_doc_ids.add(parsed_change_doc_id) + total_paid -= _sum_receipt_doc_account_payments(change_doc) remaining = total_amount - total_paid results[inv_id] = { @@ -10512,7 +10800,32 @@ def calculate_invoice_remaining( if doc.id in processed_doc_ids: continue processed_doc_ids.add(doc.id) - total_paid += _sum_receipt_doc_payments_for_invoice(doc, invoice_id, via_person_line=True) + if (doc.extra_info or {}).get("source") == "quick_sale_change": + total_paid -= _sum_receipt_doc_account_payments(doc) + else: + total_paid += _sum_receipt_doc_payments_for_invoice( + doc, invoice_id, via_person_line=True + ) + + change_doc_id = links.get(INVOICE_LINK_QUICK_SALE_CHANGE_PAYMENT_ID) + try: + parsed_change_doc_id = int(change_doc_id) if change_doc_id is not None else None + except (ValueError, TypeError): + parsed_change_doc_id = None + if parsed_change_doc_id is not None and parsed_change_doc_id not in processed_doc_ids: + change_doc = ( + db.query(Document) + .options(selectinload(Document.lines)) + .filter( + Document.id == parsed_change_doc_id, + Document.business_id == business_id, + Document.document_type == "payment", + ) + .first() + ) + if change_doc: + processed_doc_ids.add(parsed_change_doc_id) + total_paid -= _sum_receipt_doc_account_payments(change_doc) remaining = total_amount - total_paid diff --git a/hesabixAPI/app/services/quick_sales_service.py b/hesabixAPI/app/services/quick_sales_service.py index 0f722fa0..204cc380 100755 --- a/hesabixAPI/app/services/quick_sales_service.py +++ b/hesabixAPI/app/services/quick_sales_service.py @@ -1,15 +1,20 @@ from __future__ import annotations -from typing import Any, Dict, List import json import logging -from sqlalchemy.orm import Session -from sqlalchemy import and_, func +import re +from typing import Any, Dict, List + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session -from adapters.db.models.quick_sales_settings import QuickSalesSetting -from adapters.db.models.person import Person, PersonType -from adapters.db.models.business import Business from adapters.api.v1.schema_models.person import PersonCreateRequest +from adapters.db.models.bank_account import BankAccount +from adapters.db.models.business import Business +from adapters.db.models.cash_register import CashRegister +from adapters.db.models.person import Person, PersonType +from adapters.db.models.quick_sales_payment_button import QuickSalesPaymentButton +from adapters.db.models.quick_sales_settings import QuickSalesSetting from app.core.responses import ApiError from app.services.person_service import create_person @@ -21,6 +26,417 @@ DEFAULT_SHARE_EXPIRY_HOURS = 168 MIN_SHARE_EXPIRY_HOURS = 1 MAX_SHARE_EXPIRY_HOURS = 720 +PAYMENT_BUTTON_TARGETS = frozenset({"cash_register", "bank", "person"}) +PAYMENT_BUTTON_SCOPES = frozenset({"business", "user"}) +PAYMENT_BUTTON_RESERVED_SHORTCUTS = frozenset( + {"ENTER", "ESCAPE", "DELETE", "BACKSPACE", "F5", "+", "-"} +) +MAX_PAYMENT_BUTTONS_PER_SCOPE = 30 + + +def _normalize_payment_button_shortcut(value: Any) -> str | None: + shortcut = str(value or "").strip().upper().replace(" ", "") + if not shortcut: + return None + if len(shortcut) > 32: + raise ApiError("PAYMENT_BUTTON_SHORTCUT_INVALID", "میانبر بیش از حد طولانی است", http_status=400) + if shortcut in PAYMENT_BUTTON_RESERVED_SHORTCUTS: + raise ApiError( + "PAYMENT_BUTTON_SHORTCUT_RESERVED", + "این کلید در فروش سریع یا مرورگر رزرو شده است", + http_status=400, + ) + if not re.fullmatch( + r"(?:F(?:[1-9]|1[0-2])|(?:(?:CTRL|ALT|SHIFT)\+)+(?:[A-Z0-9]|F(?:[1-9]|1[0-2])))", + shortcut, + ): + raise ApiError( + "PAYMENT_BUTTON_SHORTCUT_INVALID", + "میانبر باید یکی از کلیدهای F یا ترکیبی مانند Alt+1 باشد", + http_status=400, + ) + return shortcut + + +def _payment_button_target( + db: Session, + business_id: int, + target_type: str, + target_id: Any, +) -> tuple[Any, str]: + try: + parsed_id = int(target_id) + except (TypeError, ValueError) as exc: + raise ApiError("PAYMENT_BUTTON_TARGET_REQUIRED", "مقصد پرداخت الزامی است", http_status=400) from exc + + if target_type == "cash_register": + target = ( + db.query(CashRegister) + .filter( + CashRegister.id == parsed_id, + CashRegister.business_id == int(business_id), + CashRegister.is_active == True, # noqa: E712 + ) + .first() + ) + name = getattr(target, "name", None) + elif target_type == "bank": + target = ( + db.query(BankAccount) + .filter( + BankAccount.id == parsed_id, + BankAccount.business_id == int(business_id), + BankAccount.is_active == True, # noqa: E712 + ) + .first() + ) + name = getattr(target, "name", None) + elif target_type == "person": + target = ( + db.query(Person) + .filter(Person.id == parsed_id, Person.business_id == int(business_id)) + .first() + ) + name = getattr(target, "alias_name", None) + else: + raise ApiError("PAYMENT_BUTTON_TARGET_TYPE_INVALID", "نوع مقصد پرداخت نامعتبر است", http_status=400) + + if not target: + raise ApiError("PAYMENT_BUTTON_TARGET_NOT_FOUND", "مقصد پرداخت یافت نشد یا غیرفعال است", http_status=404) + return target, str(name or "مقصد پرداخت") + + +def _payment_button_target_id(row: QuickSalesPaymentButton) -> int: + if row.target_type == "cash_register": + return int(row.cash_register_id) + if row.target_type == "bank": + return int(row.bank_account_id) + return int(row.person_id) + + +def _payment_button_to_dict(db: Session, row: QuickSalesPaymentButton) -> Dict[str, Any]: + _, target_name = _payment_button_target( + db, row.business_id, row.target_type, _payment_button_target_id(row) + ) + # برای شخص، خود نام شخص روی دکمه نمایش داده می‌شود تا عنوان مبهمی مثل «کارت شخصی» نسازیم. + label = target_name if row.target_type == "person" else (row.label or target_name) + return { + "id": int(row.id), + "business_id": int(row.business_id), + "scope": row.scope, + "owner_user_id": int(row.owner_user_id) if row.owner_user_id else None, + "target_type": row.target_type, + "target_id": _payment_button_target_id(row), + "target_name": target_name, + "label": label, + "shortcut": row.shortcut, + "position": int(row.position), + "is_active": bool(row.is_active), + "is_system": False, + } + + +def _assert_payment_button_shortcut_available( + db: Session, + *, + business_id: int, + scope: str, + owner_user_id: int | None, + shortcut: str | None, + exclude_id: int | None = None, +) -> None: + if not shortcut: + return + query = db.query(QuickSalesPaymentButton).filter( + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.is_active == True, # noqa: E712 + func.upper(QuickSalesPaymentButton.shortcut) == shortcut, + ) + if exclude_id is not None: + query = query.filter(QuickSalesPaymentButton.id != int(exclude_id)) + if scope == "business": + query = query.filter( + or_( + QuickSalesPaymentButton.scope == "business", + QuickSalesPaymentButton.scope == "user", + ) + ) + else: + query = query.filter( + or_( + QuickSalesPaymentButton.scope == "business", + and_( + QuickSalesPaymentButton.scope == "user", + QuickSalesPaymentButton.owner_user_id == int(owner_user_id), + ), + ) + ) + if query.first(): + raise ApiError( + "PAYMENT_BUTTON_SHORTCUT_CONFLICT", + "این میانبر قبلاً برای یک دکمه پرداخت استفاده شده است", + http_status=409, + ) + + +def list_quick_sales_payment_buttons( + db: Session, business_id: int, user_id: int +) -> List[Dict[str, Any]]: + rows = ( + db.query(QuickSalesPaymentButton) + .filter( + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.is_active == True, # noqa: E712 + or_( + QuickSalesPaymentButton.scope == "business", + and_( + QuickSalesPaymentButton.scope == "user", + QuickSalesPaymentButton.owner_user_id == int(user_id), + ), + ), + ) + .order_by(QuickSalesPaymentButton.position.asc(), QuickSalesPaymentButton.id.asc()) + .all() + ) + result = [_payment_button_to_dict(db, row) for row in rows] + if result: + return result + + settings = ( + db.query(QuickSalesSetting) + .filter(QuickSalesSetting.business_id == int(business_id)) + .first() + ) + register_id = getattr(settings, "default_cash_register_id", None) + if not register_id: + return [] + try: + _, name = _payment_button_target(db, business_id, "cash_register", register_id) + except ApiError: + return [] + return [{ + "id": f"system-cash-{int(register_id)}", + "business_id": int(business_id), + "scope": "system", + "owner_user_id": None, + "target_type": "cash_register", + "target_id": int(register_id), + "target_name": name, + "label": name, + "shortcut": None, + "position": 0, + "is_active": True, + "is_system": True, + }] + + +def create_quick_sales_payment_button( + db: Session, + *, + business_id: int, + user_id: int, + payload: Dict[str, Any], + can_manage_business: bool, +) -> Dict[str, Any]: + scope = str(payload.get("scope") or "user").strip().lower() + if scope not in PAYMENT_BUTTON_SCOPES: + raise ApiError("PAYMENT_BUTTON_SCOPE_INVALID", "دامنه دکمه نامعتبر است", http_status=400) + if scope == "business" and not can_manage_business: + raise ApiError("FORBIDDEN", "مجوز تعریف دکمه مشترک را ندارید", http_status=403) + target_type = str(payload.get("target_type") or "").strip().lower() + if target_type not in PAYMENT_BUTTON_TARGETS: + raise ApiError("PAYMENT_BUTTON_TARGET_TYPE_INVALID", "نوع مقصد پرداخت نامعتبر است", http_status=400) + target, target_name = _payment_button_target( + db, business_id, target_type, payload.get("target_id") + ) + owner_user_id = int(user_id) if scope == "user" else None + count = ( + db.query(func.count(QuickSalesPaymentButton.id)) + .filter( + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.scope == scope, + QuickSalesPaymentButton.owner_user_id == owner_user_id, + QuickSalesPaymentButton.is_active == True, # noqa: E712 + ) + .scalar() + or 0 + ) + if int(count) >= MAX_PAYMENT_BUTTONS_PER_SCOPE: + raise ApiError("PAYMENT_BUTTON_LIMIT", "حداکثر تعداد دکمه‌های پرداخت ثبت شده است", http_status=400) + shortcut = _normalize_payment_button_shortcut(payload.get("shortcut")) + _assert_payment_button_shortcut_available( + db, + business_id=business_id, + scope=scope, + owner_user_id=owner_user_id, + shortcut=shortcut, + ) + max_position = ( + db.query(func.max(QuickSalesPaymentButton.position)) + .filter( + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.scope == scope, + QuickSalesPaymentButton.owner_user_id == owner_user_id, + ) + .scalar() + ) + label = str(payload.get("label") or "").strip() or target_name + if target_type == "person": + label = target_name + row = QuickSalesPaymentButton( + business_id=int(business_id), + scope=scope, + owner_user_id=owner_user_id, + target_type=target_type, + cash_register_id=int(target.id) if target_type == "cash_register" else None, + bank_account_id=int(target.id) if target_type == "bank" else None, + person_id=int(target.id) if target_type == "person" else None, + label=label[:120], + shortcut=shortcut, + position=(int(max_position) + 1) if max_position is not None else 0, + is_active=True, + created_by_user_id=int(user_id), + ) + db.add(row) + db.commit() + db.refresh(row) + return _payment_button_to_dict(db, row) + + +def update_quick_sales_payment_button( + db: Session, + *, + business_id: int, + button_id: int, + user_id: int, + payload: Dict[str, Any], + can_manage_business: bool, +) -> Dict[str, Any]: + row = ( + db.query(QuickSalesPaymentButton) + .filter( + QuickSalesPaymentButton.id == int(button_id), + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.is_active == True, # noqa: E712 + ) + .first() + ) + if not row: + raise ApiError("PAYMENT_BUTTON_NOT_FOUND", "دکمه پرداخت یافت نشد", http_status=404) + if row.scope == "business" and not can_manage_business: + raise ApiError("FORBIDDEN", "مجوز ویرایش دکمه مشترک را ندارید", http_status=403) + if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id): + raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403) + + target_type = str(payload.get("target_type") or row.target_type).strip().lower() + target_id = payload.get("target_id", _payment_button_target_id(row)) + target, target_name = _payment_button_target(db, business_id, target_type, target_id) + shortcut = _normalize_payment_button_shortcut(payload.get("shortcut", row.shortcut)) + _assert_payment_button_shortcut_available( + db, + business_id=business_id, + scope=row.scope, + owner_user_id=row.owner_user_id, + shortcut=shortcut, + exclude_id=row.id, + ) + row.target_type = target_type + row.cash_register_id = int(target.id) if target_type == "cash_register" else None + row.bank_account_id = int(target.id) if target_type == "bank" else None + row.person_id = int(target.id) if target_type == "person" else None + row.label = ( + target_name + if target_type == "person" + else (str(payload.get("label") or row.label or target_name).strip() or target_name)[:120] + ) + row.shortcut = shortcut + if "position" in payload: + row.position = max(0, int(payload.get("position") or 0)) + db.commit() + db.refresh(row) + return _payment_button_to_dict(db, row) + + +def delete_quick_sales_payment_button( + db: Session, + *, + business_id: int, + button_id: int, + user_id: int, + can_manage_business: bool, +) -> None: + row = ( + db.query(QuickSalesPaymentButton) + .filter( + QuickSalesPaymentButton.id == int(button_id), + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.is_active == True, # noqa: E712 + ) + .first() + ) + if not row: + raise ApiError("PAYMENT_BUTTON_NOT_FOUND", "دکمه پرداخت یافت نشد", http_status=404) + if row.scope == "business" and not can_manage_business: + raise ApiError("FORBIDDEN", "مجوز حذف دکمه مشترک را ندارید", http_status=403) + if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id): + raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403) + row.is_active = False + db.commit() + + +def reorder_quick_sales_payment_buttons( + db: Session, + *, + business_id: int, + user_id: int, + button_ids: List[Any], + can_manage_business: bool, +) -> List[Dict[str, Any]]: + """ترتیب مؤثر دکمه‌های مشترک و شخصی کاربر را ذخیره می‌کند.""" + try: + ordered_ids = [int(value) for value in button_ids] + except (TypeError, ValueError) as exc: + raise ApiError( + "PAYMENT_BUTTON_ORDER_INVALID", + "ترتیب دکمه‌های پرداخت نامعتبر است", + http_status=400, + ) from exc + if len(ordered_ids) != len(set(ordered_ids)): + raise ApiError( + "PAYMENT_BUTTON_ORDER_INVALID", + "شناسه تکراری در ترتیب دکمه‌ها وجود دارد", + http_status=400, + ) + rows = ( + db.query(QuickSalesPaymentButton) + .filter( + QuickSalesPaymentButton.business_id == int(business_id), + QuickSalesPaymentButton.id.in_(ordered_ids), + QuickSalesPaymentButton.is_active == True, # noqa: E712 + ) + .all() + ) + by_id = {int(row.id): row for row in rows} + if set(by_id) != set(ordered_ids): + raise ApiError( + "PAYMENT_BUTTON_NOT_FOUND", + "یکی از دکمه‌های پرداخت یافت نشد", + http_status=404, + ) + for row in rows: + if row.scope == "business" and not can_manage_business: + raise ApiError( + "FORBIDDEN", + "مجوز تغییر ترتیب دکمه‌های مشترک را ندارید", + http_status=403, + ) + if row.scope == "user" and int(row.owner_user_id or 0) != int(user_id): + raise ApiError("FORBIDDEN", "این دکمه متعلق به کاربر دیگری است", http_status=403) + for position, button_id in enumerate(ordered_ids): + by_id[button_id].position = position + db.commit() + return list_quick_sales_payment_buttons(db, business_id, user_id) + def normalize_default_share_channels(value: Any) -> List[str]: """نرمال‌سازی لیست کانال‌های اشتراک‌گذاری.""" diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py index cf7e3321..062f4c30 100755 --- a/hesabixAPI/app/services/receipt_payment_service.py +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -828,8 +828,9 @@ def create_receipt_payment( logger.info(f"انتخاب حساب چک با کد: {account_code}") account = _get_fixed_account_by_code(db, account_code) elif normalized_transaction_type == "person": - # برای شخص، از حساب شخص عمومی استفاده کن - account_code = "20201" # حساب‌های پرداختنی + # دریافت نزد شخص یعنی شخص وجه کسب‌وکار را نگه می‌دارد و به کسب‌وکار بدهکار است. + # در پرداخت به شخص، حساب پرداختنی شخص استفاده می‌شود. + account_code = "10401" if is_receipt else "20201" logger.info(f"انتخاب حساب شخص با کد: {account_code}") account = _get_fixed_account_by_code(db, account_code) elif normalized_transaction_type == "wallet": @@ -2152,7 +2153,10 @@ def update_receipt_payment( elif normalized_transaction_type == "check": account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202") elif normalized_transaction_type == "person": - account = _get_fixed_account_by_code(db, "20201") + account = _get_fixed_account_by_code( + db, + "10401" if is_receipt else "20201", + ) elif account_line.get("account_id"): account = db.query(Account).filter( and_( @@ -2832,4 +2836,3 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]: "created_at": document.created_at.isoformat(), "updated_at": document.updated_at.isoformat(), } - diff --git a/hesabixAPI/migrations/versions/20261013_000001_quick_sales_payment_buttons.py b/hesabixAPI/migrations/versions/20261013_000001_quick_sales_payment_buttons.py new file mode 100644 index 00000000..4e70a324 --- /dev/null +++ b/hesabixAPI/migrations/versions/20261013_000001_quick_sales_payment_buttons.py @@ -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") diff --git a/hesabixAPI/tests/test_quick_sales_change.py b/hesabixAPI/tests/test_quick_sales_change.py new file mode 100644 index 00000000..0907c47b --- /dev/null +++ b/hesabixAPI/tests/test_quick_sales_change.py @@ -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 diff --git a/hesabixAPI/tests/test_quick_sales_service.py b/hesabixAPI/tests/test_quick_sales_service.py index b33cca1e..d23020e0 100644 --- a/hesabixAPI/tests/test_quick_sales_service.py +++ b/hesabixAPI/tests/test_quick_sales_service.py @@ -14,10 +14,11 @@ from app.core.responses import ApiError from app.services.quick_sales_service import ( DEFAULT_SHARE_CHANNELS, DEFAULT_SHARE_EXPIRY_HOURS, + _normalize_payment_button_shortcut, + _share_settings_from_obj, + get_quick_sales_settings, normalize_default_share_channels, normalize_share_expiry_hours, - get_quick_sales_settings, - _share_settings_from_obj, ) @@ -101,3 +102,17 @@ def test_validate_share_gateway_id_rejects_unknown_gateway() -> None: with pytest.raises(ApiError) as exc: _validate_share_gateway_id(db, 1, 99) assert exc.value.detail["error"]["code"] == "GATEWAY_NOT_FOUND" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("alt + 1", "ALT+1"), ("F3", "F3"), ("ctrl+shift+k", "CTRL+SHIFT+K")], +) +def test_normalize_payment_button_shortcut(raw: str, expected: str) -> None: + assert _normalize_payment_button_shortcut(raw) == expected + + +@pytest.mark.parametrize("raw", ["F5", "Enter", "plain text", "Ctrl+"]) +def test_normalize_payment_button_shortcut_rejects_invalid_or_reserved(raw: str) -> None: + with pytest.raises(ApiError): + _normalize_payment_button_shortcut(raw) diff --git a/hesabixUI/hesabix_ui/lib/models/quick_sales_payment_button.dart b/hesabixUI/hesabix_ui/lib/models/quick_sales_payment_button.dart new file mode 100644 index 00000000..ac160be5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/quick_sales_payment_button.dart @@ -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 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', + }; +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index 096b99c2..aca3dccd 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -33,6 +33,7 @@ import '../../models/quick_sales_parked_sale.dart'; import '../../widgets/invoice/customer_combobox_widget.dart'; import '../../widgets/quick_sales/quick_sales_parked_sales_bar.dart'; import '../../widgets/quick_sales/quick_sales_payment_composer.dart'; +import '../../widgets/quick_sales/quick_sales_payment_dock.dart'; import '../../widgets/invoice/warehouse_combobox_widget.dart'; import '../../utils/currency_display_utils.dart'; import '../../widgets/permission/access_denied_page.dart'; @@ -158,7 +159,7 @@ class _QuickSalesPageState extends State List _payments = []; String? _selectedCashRegisterId; bool _autoCreatePaymentDocument = true; - bool _cashFollowsTotal = true; + bool _cashFollowsTotal = false; bool _shareRemainingEnabled = false; // اشتراک‌گذاری (وقتی سند دریافت ثبت نمی‌شود) @@ -698,7 +699,7 @@ class _QuickSalesPageState extends State _selectedCustomer = _anonymousCustomer; _cartItems = []; _payments = []; - _cashFollowsTotal = true; + _cashFollowsTotal = false; _shareRemainingEnabled = false; _checkoutDiscountExpanded = false; _selectedCashRegisterId = _settingsDefaultCashRegisterId; @@ -1042,14 +1043,15 @@ class _QuickSalesPageState extends State _autoCreatePaymentDocument = value; _shareRemainingEnabled = false; _payments = []; - _cashFollowsTotal = true; + _cashFollowsTotal = false; if (!value) { _syncShareChannelDefaults(); } }); } - num get _paidAmount => _payments.fold(0, (sum, p) => sum + p.amount); + num get _paidAmount => + _payments.fold(0, (sum, p) => sum + p.settlesAgainstInvoice); num get _paymentEpsilon { if (_invoiceCurrencyDecimalPlaces <= 0) return 0.5; @@ -1071,6 +1073,8 @@ class _QuickSalesPageState extends State _autoCreatePaymentDocument && _paidAmount > _totalAmount + _paymentEpsilon; + num get _changeAmount => _isOverpaid ? _paidAmount - _totalAmount : 0; + bool get _isAnonymousSelected { if (_selectedCustomer == null || _anonymousCustomer == null) { return _selectedCustomer == null; @@ -1139,6 +1143,11 @@ class _QuickSalesPageState extends State if (id != null) map['check_id'] = id; if (p.checkNumber != null) map['check_number'] = p.checkNumber; break; + case TransactionType.person: + final id = int.tryParse(p.personId ?? ''); + if (id != null) map['person_id'] = id; + if (p.personName != null) map['person_name'] = p.personName; + break; default: break; } @@ -1149,7 +1158,6 @@ class _QuickSalesPageState extends State String? _validatePayments(AppLocalizations t) { if (!_autoCreatePaymentDocument) return null; - if (_isOverpaid) return t.quickSalesPayOverpaidError; for (final p in _payments) { if (p.amount <= 0) continue; switch (p.type) { @@ -1168,6 +1176,11 @@ class _QuickSalesPageState extends State return t.quickSalesPaySelectCheck; } break; + case TransactionType.person: + if (int.tryParse(p.personId ?? '') == null) { + return t.quickSalesPayNeedDestination; + } + break; default: return t.quickSalesPayNeedDestination; } @@ -2470,6 +2483,8 @@ class _QuickSalesPageState extends State Future _saveInvoice({ bool print = false, Map? stockOverride, + bool changeConfirmed = false, + bool debtConfirmed = false, }) async { // اعتبارسنجی سبد خرید if (_cartItems.isEmpty) { @@ -2507,6 +2522,64 @@ class _QuickSalesPageState extends State return; } } + if (_autoCreatePaymentDocument && + _payments.isEmpty && + _totalAmount > 0 && + !debtConfirmed) { + final confirmed = await showDialog( + 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( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('تأیید برگشت وجه'), + content: Text( + '${_formatNumber(_changeAmount)} $_paymentCurrencyUnit از صندوق نقدی به مشتری برگردانده شده است؟', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('تأیید برگشت وجه'), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + changeConfirmed = true; + } if (_shouldShareAfterSave) { if (_shareOnlinePaymentEnabled) { if (_shareGateways.isEmpty) { @@ -2643,6 +2716,12 @@ class _QuickSalesPageState extends State 'tax': _totalTax, 'net': _totalAmount, }, + if (_isOverpaid) + 'quick_sale_change': { + 'amount': _changeAmount, + 'cash_register_id': int.tryParse(_selectedCashRegisterId ?? ''), + 'confirmed_returned': changeConfirmed, + }, }; final gvSave = _parsedGlobalDiscountValue; @@ -2683,7 +2762,7 @@ class _QuickSalesPageState extends State setState(() { _cartItems.clear(); _payments = []; - _cashFollowsTotal = true; + _cashFollowsTotal = false; _shareRemainingEnabled = false; _checkoutDiscountExpanded = false; _productStocks.clear(); @@ -2773,7 +2852,12 @@ class _QuickSalesPageState extends State if (parkAfterFailure) { _startNewSale(); } else if (retryAfterCorrection || retryOverride != null) { - await _saveInvoice(print: print, stockOverride: retryOverride); + await _saveInvoice( + print: print, + stockOverride: retryOverride, + changeConfirmed: changeConfirmed, + debtConfirmed: debtConfirmed, + ); } } @@ -4278,12 +4362,39 @@ class _QuickSalesPageState extends State ); return _withParkedSalesBar( - Row( + Column( children: [ - // ستون چپ: سبد خرید - Expanded(flex: 7, child: cartColumn), - // ستون راست: خلاصه و پرداخت - _buildDesktopCheckoutPanel(cs), + Expanded( + child: Row( + children: [ + // ستون اصلی: سبد خرید + Expanded(flex: 7, child: cartColumn), + // ستون کناری: مشتری، جمع و جزئیات سند + _buildDesktopCheckoutPanel(cs), + ], + ), + ), + if (_autoCreatePaymentDocument) + QuickSalesPaymentDock( + key: ValueKey('payment-dock-$_activeSaleId'), + businessId: widget.businessId, + authStore: widget.authStore, + payments: _payments, + onChanged: _onPaymentsChanged, + invoiceTotal: _totalAmount, + defaultCashRegisterId: _selectedCashRegisterId, + currencyUnit: _paymentCurrencyUnit, + decimalPlaces: _invoiceCurrencyDecimalPlaces, + enabled: !_isSaving, + onSave: () => _saveInvoice(print: false), + onSaveAndPrint: () => _saveInvoice(print: true), + ) + else + Material( + elevation: 3, + color: cs.surface, + child: _buildCheckoutButtons(cs), + ), ], ), compact: isCompact, @@ -5089,28 +5200,17 @@ class _QuickSalesPageState extends State return Container( width: 384, color: cs.surfaceContainerHighest, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: _buildCheckoutScrollBody( - cs, - includeCustomer: true, - includeDocumentMeta: true, - ), - ), - ), + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _buildCheckoutScrollBody( + cs, + includeCustomer: true, + includeDocumentMeta: true, + includePayment: false, ), - Material( - elevation: 2, - color: cs.surface, - child: _buildCheckoutButtons(cs), - ), - ], + ), ), ); } @@ -5119,6 +5219,7 @@ class _QuickSalesPageState extends State ColorScheme cs, { required bool includeCustomer, required bool includeDocumentMeta, + bool includePayment = true, }) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -5146,9 +5247,44 @@ class _QuickSalesPageState extends State const SizedBox(height: 4), _buildCheckoutDiscountBlock(cs), const SizedBox(height: 10), - _buildPaymentSection(cs), - const SizedBox(height: 4), + if (includePayment) ...[ + _buildPaymentSection(cs), + const SizedBox(height: 4), + ], _buildCheckoutDetailsTile(cs, includeDocumentMeta: includeDocumentMeta), + if (!includePayment) ...[ + const SizedBox(height: 8), + _buildDesktopShareControls(cs), + ], + ], + ); + } + + Widget _buildDesktopShareControls(ColorScheme cs) { + final t = AppLocalizations.of(context); + if (!_autoCreatePaymentDocument) return _buildShareSection(cs, t); + if (_remainingAmount <= 0 || _isOverpaid) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + dense: true, + title: Tooltip( + message: t.quickSalesPayShareRemainingHint, + child: Text(t.quickSalesPayShareRemaining), + ), + value: _shareRemainingEnabled, + onChanged: _isSaving + ? null + : (value) { + setState(() { + _shareRemainingEnabled = value; + if (value) _syncShareChannelDefaults(); + }); + }, + ), + if (_shareRemainingEnabled) _buildShareSection(cs, t), ], ); } @@ -5639,190 +5775,6 @@ class _QuickSalesPageState extends State ); } - 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( - 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( - value: 'save', - child: Row( - children: [ - Icon(Icons.save), - SizedBox(width: 10), - Text('ثبت'), - ], - ), - ), - PopupMenuItem( - 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 _openCheckoutSheet(ColorScheme cs) async { - if (_cartItems.isEmpty) return; - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - useSafeArea: true, - backgroundColor: cs.surface, - builder: (context) { - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.72, - minChildSize: 0.45, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Column( - children: [ - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.only(bottom: 12), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: _buildCheckoutScrollBody( - cs, - includeCustomer: true, - includeDocumentMeta: true, - ), - ), - ], - ), - ), - Container( - decoration: BoxDecoration( - color: cs.surface, - border: Border( - top: BorderSide( - color: cs.outlineVariant.withOpacity(0.4), - ), - ), - ), - child: SafeArea(top: false, child: _buildCheckoutButtons(cs)), - ), - ], - ); - }, - ); - }, - ); - } - Widget _buildSummaryRow( String label, String value, { diff --git a/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart b/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart index ed5f2011..a52d2115 100755 --- a/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart @@ -1,4 +1,5 @@ import '../core/api_client.dart'; +import '../models/quick_sales_payment_button.dart'; class QuickSalesService { final ApiClient _api; @@ -35,6 +36,80 @@ class QuickSalesService { return Map.from(res.data?['data'] ?? const {}); } + Future> listPaymentButtons({ + required int businessId, + }) async { + final res = await _api.get>( + '/api/v1/businesses/$businessId/quick-sales/payment-buttons', + ); + final raw = res.data?['data']; + if (raw is! List) return const []; + return raw + .whereType() + .map( + (e) => QuickSalesPaymentButtonModel.fromJson( + Map.from(e), + ), + ) + .toList(); + } + + Future createPaymentButton({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/businesses/$businessId/quick-sales/payment-buttons', + data: payload, + ); + return QuickSalesPaymentButtonModel.fromJson( + Map.from(res.data?['data'] ?? const {}), + ); + } + + Future updatePaymentButton({ + required int businessId, + required int buttonId, + required Map payload, + }) async { + final res = await _api.put>( + '/api/v1/businesses/$businessId/quick-sales/payment-buttons/$buttonId', + data: payload, + ); + return QuickSalesPaymentButtonModel.fromJson( + Map.from(res.data?['data'] ?? const {}), + ); + } + + Future deletePaymentButton({ + required int businessId, + required int buttonId, + }) async { + await _api.delete>( + '/api/v1/businesses/$businessId/quick-sales/payment-buttons/$buttonId', + ); + } + + Future> reorderPaymentButtons({ + required int businessId, + required List buttonIds, + }) async { + final res = await _api.put>( + '/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( + (row) => QuickSalesPaymentButtonModel.fromJson( + Map.from(row), + ), + ) + .toList(); + } + Future> createStockCorrection({ required int businessId, required Map payload, diff --git a/hesabixUI/hesabix_ui/lib/utils/quick_sales_payment_balance.dart b/hesabixUI/hesabix_ui/lib/utils/quick_sales_payment_balance.dart index 59bc3705..9a5d0eca 100644 --- a/hesabixUI/hesabix_ui/lib/utils/quick_sales_payment_balance.dart +++ b/hesabixUI/hesabix_ui/lib/utils/quick_sales_payment_balance.dart @@ -27,7 +27,7 @@ bool canAddQuickSalesPaymentLine({ required List payments, required bool enabled, required num epsilon, - int maxLines = 6, + int maxLines = 50, }) { if (!enabled) return false; if (payments.length >= maxLines) return false; @@ -37,11 +37,10 @@ bool canAddQuickSalesPaymentLine({ return true; } -/// اعمال مبلغ روی یک ردیف و حفظ تراز با فاکتور. +/// اعمال مبلغ روی یک ردیف پرداخت. /// -/// اگر جمع از مبلغ فاکتور بیشتر شود، از ردیف‌های دیگر (از آخر) -/// کم می‌شود تا دفاتر از حد فاکتور رد نشوند. ردیف‌های دیگر که صفر -/// شدند حذف می‌شوند. ردیف در حال ویرایش حتی با مبلغ صفر می‌ماند. +/// پرداخت‌ها مستقل از یکدیگر نگه داشته می‌شوند. جمع بیشتر از مبلغ +/// فاکتور مجاز است و در فروش سریع به صورت وجه برگشتی ثبت می‌شود. List applyQuickSalesPaymentAmount({ required List payments, required int index, @@ -53,35 +52,8 @@ List applyQuickSalesPaymentAmount({ return List.from(payments); } - final epsilon = quickSalesPaymentEpsilon(decimalPlaces); final next = List.from(payments); final safe = amount < 0 ? 0 : roundQuickSalesMoney(amount, decimalPlaces); next[index] = next[index].copyWith(amount: safe); - - var paid = next.fold(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; } diff --git a/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_composer.dart b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_composer.dart index 91190ead..e06eea6b 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_composer.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_composer.dart @@ -61,7 +61,7 @@ class QuickSalesPaymentComposer extends StatefulWidget { class _QuickSalesPaymentComposerState extends State { static const _uuid = Uuid(); - static const _maxLines = 6; + static const _maxLines = 50; String? _lastBankId; String? _focusAmountId; @@ -240,8 +240,7 @@ class _QuickSalesPaymentComposerState extends State { _emit([...widget.payments, tx]); } - int _indexOfId(String id) => - widget.payments.indexWhere((p) => p.id == id); + int _indexOfId(String id) => widget.payments.indexWhere((p) => p.id == id); void _replaceAt(int index, InvoiceTransaction tx) { if (index < 0 || index >= widget.payments.length) return; @@ -382,8 +381,7 @@ class _QuickSalesPaymentComposerState extends State { currencyUnit: widget.currencyUnit, enabled: widget.enabled, autofocus: false, - onAmountChanged: (v) => - _onAmountEditedById(widget.payments.first.id, v), + onAmountChanged: (v) => _onAmountEditedById(widget.payments.first.id, v), ); return LayoutBuilder( diff --git a/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart new file mode 100644 index 00000000..9b72ebef --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart @@ -0,0 +1,1132 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../../core/auth_store.dart'; +import '../../models/invoice_transaction.dart'; +import '../../models/person_model.dart'; +import '../../models/quick_sales_payment_button.dart'; +import '../../services/person_service.dart'; +import '../../services/quick_sales_service.dart'; +import '../../utils/error_extractor.dart'; +import '../../utils/number_formatters.dart'; +import '../../utils/number_normalizer.dart'; +import '../../utils/snackbar_helper.dart'; +import '../invoice/bank_account_combobox_widget.dart'; +import '../invoice/cash_register_combobox_widget.dart'; +import '../invoice/person_combobox_widget.dart'; + +class QuickSalesPaymentDock extends StatefulWidget { + final int businessId; + final AuthStore authStore; + final List payments; + final ValueChanged> onChanged; + final num invoiceTotal; + final String? defaultCashRegisterId; + final String currencyUnit; + final int decimalPlaces; + final bool enabled; + final VoidCallback onSave; + final VoidCallback onSaveAndPrint; + + const QuickSalesPaymentDock({ + super.key, + required this.businessId, + required this.authStore, + required this.payments, + required this.onChanged, + required this.invoiceTotal, + required this.defaultCashRegisterId, + required this.currencyUnit, + required this.decimalPlaces, + required this.enabled, + required this.onSave, + required this.onSaveAndPrint, + }); + + @override + State createState() => _QuickSalesPaymentDockState(); +} + +class _QuickSalesPaymentDockState extends State { + static const _uuid = Uuid(); + static const double _defaultButtonWidth = 510; + static const double _minButtonWidth = 330; + static const double _minListWidth = 300; + static const double _summaryWidth = 280; + static const int _maxPaymentLines = 50; + + final QuickSalesService _service = QuickSalesService(); + final TextEditingController _amountController = TextEditingController(); + final FocusNode _amountFocus = FocusNode(); + final ScrollController _paymentScroll = ScrollController(); + final ScrollController _buttonScroll = ScrollController(); + List _buttons = const []; + bool _loadingButtons = true; + double _buttonWidth = _defaultButtonWidth; + + num get _epsilon { + if (widget.decimalPlaces <= 0) return 0.5; + var value = 1.0; + for (var i = 0; i < widget.decimalPlaces; i++) { + value /= 10; + } + return value / 2; + } + + num get _paid => + widget.payments.fold(0, (sum, p) => sum + p.settlesAgainstInvoice); + num get _remaining => (widget.invoiceTotal - _paid) > _epsilon + ? widget.invoiceTotal - _paid + : 0; + num get _change => (_paid - widget.invoiceTotal) > _epsilon + ? _paid - widget.invoiceTotal + : 0; + + String get _preferenceKey => + 'quick_sales_payment_dock_buttons_width_${widget.businessId}_${widget.authStore.currentUserId ?? 0}'; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_handleShortcutKeyEvent); + _amountFocus.addListener(() { + if (_amountFocus.hasFocus) { + _amountController.selection = TextSelection( + baseOffset: 0, + extentOffset: _amountController.text.length, + ); + } + }); + _syncAmount(); + unawaited(_loadButtons()); + unawaited(_loadWidth()); + } + + @override + void didUpdateWidget(covariant QuickSalesPaymentDock oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.invoiceTotal != widget.invoiceTotal || + oldWidget.payments != widget.payments) { + _syncAmount(); + } + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleShortcutKeyEvent); + _amountController.dispose(); + _amountFocus.dispose(); + _paymentScroll.dispose(); + _buttonScroll.dispose(); + super.dispose(); + } + + void _syncAmount() { + final next = formatNumberForInput( + _remaining, + decimalPlaces: widget.decimalPlaces, + ); + if (_amountController.text == next) return; + _amountController.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + } + + bool _handleShortcutKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent || !widget.enabled || _loadingButtons) { + return false; + } + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) return false; + final key = event.logicalKey.keyLabel.trim().toUpperCase(); + if (key.isEmpty) return false; + final keyboard = HardwareKeyboard.instance; + final parts = [ + if (keyboard.isControlPressed || keyboard.isMetaPressed) 'CTRL', + if (keyboard.isAltPressed) 'ALT', + if (keyboard.isShiftPressed) 'SHIFT', + key, + ]; + final shortcut = parts.join('+'); + for (final button in _buttons) { + if ((button.shortcut ?? '').replaceAll(' ', '').toUpperCase() == + shortcut) { + _addPayment(button); + return true; + } + } + return false; + } + + Future _loadButtons() async { + try { + final rows = await _service.listPaymentButtons( + businessId: widget.businessId, + ); + if (!mounted) return; + setState(() { + _buttons = rows; + _loadingButtons = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loadingButtons = false); + SnackBarHelper.show( + context, + message: ErrorExtractor.forContext(e, context), + isError: true, + ); + } + } + + Future _loadWidth() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getDouble(_preferenceKey); + if (mounted && value != null) setState(() => _buttonWidth = value); + } + + Future _saveWidth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_preferenceKey, _buttonWidth); + } + + String _format(num value) => + formatWithThousands(value, decimalPlaces: widget.decimalPlaces); + + void _addPayment(QuickSalesPaymentButtonModel button) { + if (!widget.enabled) return; + if (widget.payments.length >= _maxPaymentLines) { + SnackBarHelper.show( + context, + message: 'حداکثر ۵۰ ردیف پرداخت برای یک فاکتور مجاز است.', + isError: true, + ); + return; + } + final amount = parseFormattedNumber(_amountController.text) ?? 0; + if (amount <= _epsilon) { + SnackBarHelper.show( + context, + message: 'مبلغ پرداخت را وارد کنید.', + isError: true, + ); + _amountFocus.requestFocus(); + return; + } + final tx = switch (button.targetType) { + QuickSalesPaymentTargetType.cashRegister => InvoiceTransaction( + id: _uuid.v4(), + type: TransactionType.cashRegister, + cashRegisterId: button.targetId.toString(), + cashRegisterName: button.targetName, + transactionDate: DateTime.now(), + amount: amount, + ), + QuickSalesPaymentTargetType.bank => InvoiceTransaction( + id: _uuid.v4(), + type: TransactionType.bank, + bankId: button.targetId.toString(), + bankName: button.targetName, + transactionDate: DateTime.now(), + amount: amount, + ), + QuickSalesPaymentTargetType.person => InvoiceTransaction( + id: _uuid.v4(), + type: TransactionType.person, + personId: button.targetId.toString(), + personName: button.targetName, + transactionDate: DateTime.now(), + amount: amount, + ), + }; + widget.onChanged([...widget.payments, tx]); + } + + void _removePayment(String id) { + widget.onChanged(widget.payments.where((p) => p.id != id).toList()); + } + + void _updatePaymentAmount(InvoiceTransaction payment, String raw) { + final amount = parseFormattedNumber(raw) ?? 0; + if (amount <= _epsilon) { + _removePayment(payment.id); + return; + } + widget.onChanged([ + for (final row in widget.payments) + if (row.id == payment.id) row.copyWith(amount: amount) else row, + ]); + } + + String _paymentTitle(InvoiceTransaction payment) => switch (payment.type) { + TransactionType.cashRegister => + 'دریافت در ${payment.cashRegisterName ?? 'صندوق'}', + TransactionType.bank => 'دریافت در ${payment.bankName ?? 'بانک'}', + TransactionType.person => 'دریافت نزد ${payment.personName ?? 'شخص'}', + TransactionType.check => 'دریافت با چک ${payment.checkNumber ?? ''}', + _ => payment.type.label, + }; + + IconData _paymentIcon(TransactionType type) => switch (type) { + TransactionType.cashRegister => Icons.payments_outlined, + TransactionType.bank => Icons.account_balance, + TransactionType.person => Icons.person_outline, + TransactionType.check => Icons.receipt_long, + _ => Icons.account_balance_wallet_outlined, + }; + + IconData _buttonIcon(QuickSalesPaymentTargetType type) => switch (type) { + QuickSalesPaymentTargetType.cashRegister => Icons.payments_outlined, + QuickSalesPaymentTargetType.bank => Icons.account_balance, + QuickSalesPaymentTargetType.person => Icons.person_outline, + }; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + elevation: 6, + color: cs.surface, + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 1050) { + return SizedBox(height: 260, child: _buildCompact(cs)); + } + final maxButtons = + (constraints.maxWidth - _summaryWidth - _minListWidth - 12) + .clamp(_minButtonWidth, constraints.maxWidth * .62) + .toDouble(); + final buttonWidth = _buttonWidth + .clamp(_minButtonWidth, maxButtons) + .toDouble(); + return SizedBox( + height: 220, + child: Row( + textDirection: TextDirection.rtl, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: buttonWidth, child: _buildAmountAndButtons(cs)), + _ResizeHandle( + onReset: () { + setState( + () => _buttonWidth = _defaultButtonWidth + .clamp(_minButtonWidth, maxButtons) + .toDouble(), + ); + unawaited(_saveWidth()); + }, + onDrag: (dx) { + setState(() { + _buttonWidth = (_buttonWidth - dx) + .clamp(_minButtonWidth, maxButtons) + .toDouble(); + }); + }, + onDragEnd: () => unawaited(_saveWidth()), + onNudge: (dx) { + setState(() { + _buttonWidth = (_buttonWidth + dx) + .clamp(_minButtonWidth, maxButtons) + .toDouble(); + }); + unawaited(_saveWidth()); + }, + ), + Expanded(child: _buildPaymentList(cs)), + VerticalDivider( + width: 1, + thickness: 1, + color: cs.outlineVariant, + ), + SizedBox(width: _summaryWidth, child: _buildSummary(cs)), + ], + ), + ); + }, + ), + ); + } + + Widget _buildCompact(ColorScheme cs) { + return Column( + children: [ + Expanded( + child: Row( + textDirection: TextDirection.rtl, + children: [ + Expanded(child: _buildAmountAndButtons(cs)), + Expanded(child: _buildPaymentList(cs)), + ], + ), + ), + SizedBox(height: 76, child: _buildSummary(cs, horizontal: true)), + ], + ); + } + + Widget _buildSummary(ColorScheme cs, {bool horizontal = false}) { + final figures = [ + _SummaryValue(label: 'جمع کل', value: _format(widget.invoiceTotal)), + _SummaryValue(label: 'پرداخت‌شده', value: _format(_paid)), + ]; + final actions = [ + OutlinedButton.icon( + onPressed: widget.enabled ? widget.onSave : null, + icon: const Icon(Icons.save_outlined), + label: const Text('ثبت'), + ), + FilledButton.icon( + onPressed: widget.enabled ? widget.onSaveAndPrint : null, + icon: const Icon(Icons.print_outlined), + label: const Text('ثبت و چاپ'), + ), + ]; + if (horizontal) { + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + ...figures.map((e) => Expanded(child: e)), + ...actions.map( + (e) => Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: e, + ), + ), + ], + ), + ); + } + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...figures, + const Spacer(), + Row( + children: [ + Expanded(child: actions.first), + const SizedBox(width: 8), + Expanded(flex: 2, child: actions.last), + ], + ), + ], + ), + ); + } + + Widget _buildPaymentList(ColorScheme cs) { + if (widget.payments.isEmpty) { + return Center( + child: Text( + 'هنوز پرداختی ثبت نشده است', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ); + } + return Scrollbar( + controller: _paymentScroll, + thumbVisibility: true, + child: ListView.separated( + controller: _paymentScroll, + padding: const EdgeInsets.all(10), + itemCount: widget.payments.length, + separatorBuilder: (context, index) => + Divider(height: 1, color: cs.outlineVariant), + itemBuilder: (context, index) { + final payment = widget.payments[index]; + return SizedBox( + height: 58, + child: Row( + children: [ + Icon(_paymentIcon(payment.type), color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + _paymentTitle(payment), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 145, + child: TextFormField( + key: ValueKey('payment-${payment.id}'), + initialValue: formatNumberForInput( + payment.amount, + decimalPlaces: widget.decimalPlaces, + ), + enabled: widget.enabled, + textAlign: TextAlign.end, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + const EnglishDigitsFormatter(), + ThousandsSeparatorInputFormatter( + allowDecimal: widget.decimalPlaces > 0, + ), + ], + decoration: InputDecoration( + isDense: true, + suffixText: widget.currencyUnit, + border: const OutlineInputBorder(), + ), + onChanged: (value) { + final amount = parseFormattedNumber(value); + if (amount != null && amount > _epsilon) { + _updatePaymentAmount(payment, value); + } + }, + onFieldSubmitted: (value) => + _updatePaymentAmount(payment, value), + ), + ), + IconButton( + tooltip: 'حذف پرداخت', + onPressed: widget.enabled + ? () => _removePayment(payment.id) + : null, + icon: const Icon(Icons.close), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildAmountAndButtons(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _amountController, + focusNode: _amountFocus, + enabled: widget.enabled, + textAlign: TextAlign.end, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + const EnglishDigitsFormatter(), + ThousandsSeparatorInputFormatter( + allowDecimal: widget.decimalPlaces > 0, + ), + ], + decoration: InputDecoration( + labelText: 'مبلغ پرداخت', + suffixText: widget.currencyUnit, + isDense: true, + border: const OutlineInputBorder(), + ), + ), + if (_change > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + 'مبلغ قابل برگشت: ${_format(_change)} ${widget.currencyUnit}', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: _loadingButtons + ? const Center(child: CircularProgressIndicator()) + : Scrollbar( + controller: _buttonScroll, + thumbVisibility: _buttons.length > 6, + child: GridView.builder( + controller: _buttonScroll, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 155, + mainAxisExtent: 72, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _buttons.length + 1, + itemBuilder: (context, index) { + if (index == _buttons.length) { + return OutlinedButton.icon( + onPressed: widget.enabled + ? () => _openEditor() + : null, + icon: const Icon(Icons.add), + label: const Text( + 'تعریف دکمه', + textAlign: TextAlign.center, + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: cs.primary, + style: BorderStyle.solid, + ), + ), + ); + } + final button = _buttons[index]; + final canReorder = + !button.isSystem && + (widget.authStore.hasBusinessPermission( + 'settings', + 'business', + ) || + _buttons.every((row) => row.isPersonal)); + return _PaymentTargetTile( + button: button, + icon: _buttonIcon(button.targetType), + enabled: widget.enabled, + onPressed: () => _addPayment(button), + onEdit: button.isSystem + ? null + : () => _openEditor(existing: button), + onDelete: button.isSystem + ? null + : () => _deleteButton(button), + onMoveBefore: canReorder && index > 0 + ? () => _moveButton(index, -1) + : null, + onMoveAfter: canReorder && index < _buttons.length - 1 + ? () => _moveButton(index, 1) + : null, + ); + }, + ), + ), + ), + ], + ), + ); + } + + Future _deleteButton(QuickSalesPaymentButtonModel button) async { + final numericId = button.id is num + ? (button.id as num).toInt() + : int.tryParse('${button.id}'); + if (numericId == null) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('حذف دکمه پرداخت'), + content: Text('دکمه «${button.label}» حذف شود؟'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('حذف'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await _service.deletePaymentButton( + businessId: widget.businessId, + buttonId: numericId, + ); + await _loadButtons(); + } catch (e) { + if (mounted) { + SnackBarHelper.show( + context, + message: ErrorExtractor.forContext(e, context), + isError: true, + ); + } + } + } + + Future _moveButton(int index, int offset) async { + final destination = index + offset; + if (index < 0 || + index >= _buttons.length || + destination < 0 || + destination >= _buttons.length) { + return; + } + final next = List.from(_buttons); + final moved = next.removeAt(index); + next.insert(destination, moved); + final ids = []; + for (final button in next) { + final id = button.id is num + ? (button.id as num).toInt() + : int.tryParse('${button.id}'); + if (id == null) return; + ids.add(id); + } + setState(() => _buttons = next); + try { + final saved = await _service.reorderPaymentButtons( + businessId: widget.businessId, + buttonIds: ids, + ); + if (mounted) setState(() => _buttons = saved); + } catch (e) { + if (!mounted) return; + await _loadButtons(); + if (mounted) { + SnackBarHelper.show( + context, + message: ErrorExtractor.forContext(e, context), + isError: true, + ); + } + } + } + + Future _openEditor({QuickSalesPaymentButtonModel? existing}) async { + Person? selectedPerson; + if (existing?.targetType == QuickSalesPaymentTargetType.person) { + try { + selectedPerson = await PersonService().getPerson(existing!.targetId); + } catch (_) {} + } + if (!mounted) return; + final changed = await showDialog( + context: context, + builder: (context) => _PaymentButtonEditorDialog( + businessId: widget.businessId, + service: _service, + existing: existing, + initialPerson: selectedPerson, + canCreateShared: widget.authStore.hasBusinessPermission( + 'settings', + 'business', + ), + ), + ); + if (changed == true) await _loadButtons(); + } +} + +class _ResizeHandle extends StatelessWidget { + final ValueChanged onDrag; + final ValueChanged onNudge; + final VoidCallback onDragEnd; + final VoidCallback onReset; + + const _ResizeHandle({ + required this.onDrag, + required this.onNudge, + required this.onDragEnd, + required this.onReset, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Focus( + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + onNudge(24); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + onNudge(-24); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: GestureDetector( + onDoubleTap: onReset, + onHorizontalDragUpdate: (details) => onDrag(details.delta.dx), + onHorizontalDragEnd: (_) => onDragEnd(), + child: Semantics( + label: 'تغییر عرض دکمه‌ها و فهرست پرداخت', + hint: 'با کلیدهای جهت چپ و راست نیز قابل تغییر است', + child: Container( + width: 12, + color: Colors.transparent, + alignment: Alignment.center, + child: Container( + width: 3, + height: 54, + decoration: BoxDecoration( + color: cs.outlineVariant, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SummaryValue extends StatelessWidget { + final String label; + final String value; + const _SummaryValue({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text(value, style: const TextStyle(fontWeight: FontWeight.w700)), + ], + ), + ); + } +} + +class _PaymentTargetTile extends StatelessWidget { + final QuickSalesPaymentButtonModel button; + final IconData icon; + final bool enabled; + final VoidCallback onPressed; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onMoveBefore; + final VoidCallback? onMoveAfter; + + const _PaymentTargetTile({ + required this.button, + required this.icon, + required this.enabled, + required this.onPressed, + this.onEdit, + this.onDelete, + this.onMoveBefore, + this.onMoveAfter, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: OutlinedButton( + onPressed: enabled ? onPressed : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 20), + const SizedBox(height: 3), + Text( + button.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (button.shortcut != null) + Text( + button.shortcut!, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + if (onEdit != null || + onDelete != null || + onMoveBefore != null || + onMoveAfter != null) + PositionedDirectional( + top: 0, + end: 0, + child: PopupMenuButton( + padding: EdgeInsets.zero, + iconSize: 17, + tooltip: 'مدیریت دکمه', + onSelected: (value) { + switch (value) { + case 'edit': + onEdit?.call(); + break; + case 'delete': + onDelete?.call(); + break; + case 'before': + onMoveBefore?.call(); + break; + case 'after': + onMoveAfter?.call(); + break; + } + }, + itemBuilder: (_) => [ + if (onEdit != null) + const PopupMenuItem(value: 'edit', child: Text('ویرایش')), + if (onMoveBefore != null) + const PopupMenuItem( + value: 'before', + child: Text('جابه‌جایی به قبل'), + ), + if (onMoveAfter != null) + const PopupMenuItem( + value: 'after', + child: Text('جابه‌جایی به بعد'), + ), + if (onDelete != null) + const PopupMenuItem(value: 'delete', child: Text('حذف')), + ], + ), + ), + ], + ); + } +} + +class _PaymentButtonEditorDialog extends StatefulWidget { + final int businessId; + final QuickSalesService service; + final QuickSalesPaymentButtonModel? existing; + final Person? initialPerson; + final bool canCreateShared; + + const _PaymentButtonEditorDialog({ + required this.businessId, + required this.service, + required this.existing, + required this.initialPerson, + required this.canCreateShared, + }); + + @override + State<_PaymentButtonEditorDialog> createState() => + _PaymentButtonEditorDialogState(); +} + +class _PaymentButtonEditorDialogState + extends State<_PaymentButtonEditorDialog> { + late QuickSalesPaymentTargetType _type; + late final TextEditingController _labelController; + late final TextEditingController _shortcutController; + String? _cashId; + String? _bankId; + Person? _person; + bool _shared = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final existing = widget.existing; + _type = existing?.targetType ?? QuickSalesPaymentTargetType.cashRegister; + _labelController = TextEditingController( + text: existing?.targetType == QuickSalesPaymentTargetType.person + ? '' + : existing?.label ?? '', + ); + _shortcutController = TextEditingController(text: existing?.shortcut ?? ''); + _shared = existing?.scope == 'business'; + if (existing?.targetType == QuickSalesPaymentTargetType.cashRegister) { + _cashId = existing!.targetId.toString(); + } + if (existing?.targetType == QuickSalesPaymentTargetType.bank) { + _bankId = existing!.targetId.toString(); + } + _person = widget.initialPerson; + } + + @override + void dispose() { + _labelController.dispose(); + _shortcutController.dispose(); + super.dispose(); + } + + int? get _targetId => switch (_type) { + QuickSalesPaymentTargetType.cashRegister => int.tryParse(_cashId ?? ''), + QuickSalesPaymentTargetType.bank => int.tryParse(_bankId ?? ''), + QuickSalesPaymentTargetType.person => _person?.id, + }; + + Future _save() async { + if (_targetId == null) { + SnackBarHelper.show( + context, + message: 'مقصد پرداخت را انتخاب کنید.', + isError: true, + ); + return; + } + setState(() => _saving = true); + final payload = { + 'scope': _shared ? 'business' : 'user', + 'target_type': QuickSalesPaymentButtonModel.targetTypeValue(_type), + 'target_id': _targetId, + if (_type != QuickSalesPaymentTargetType.person) + 'label': _labelController.text.trim(), + 'shortcut': _shortcutController.text.trim(), + }; + try { + final existingId = widget.existing?.id; + final numericId = existingId is num + ? existingId.toInt() + : int.tryParse('$existingId'); + if (numericId == null) { + await widget.service.createPaymentButton( + businessId: widget.businessId, + payload: payload, + ); + } else { + await widget.service.updatePaymentButton( + businessId: widget.businessId, + buttonId: numericId, + payload: payload, + ); + } + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + SnackBarHelper.show( + context, + message: ErrorExtractor.forContext(e, context), + isError: true, + ); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.existing == null ? 'تعریف دکمه پرداخت' : 'ویرایش دکمه پرداخت', + ), + content: SizedBox( + width: 480, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration( + labelText: 'نوع مقصد', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem( + value: QuickSalesPaymentTargetType.cashRegister, + child: Text('صندوق'), + ), + DropdownMenuItem( + value: QuickSalesPaymentTargetType.bank, + child: Text('بانک'), + ), + DropdownMenuItem( + value: QuickSalesPaymentTargetType.person, + child: Text('شخص'), + ), + ], + onChanged: _saving + ? null + : (value) => setState(() => _type = value!), + ), + const SizedBox(height: 12), + if (_type == QuickSalesPaymentTargetType.cashRegister) + CashRegisterComboboxWidget( + businessId: widget.businessId, + selectedRegisterId: _cashId, + dense: true, + isRequired: true, + onChanged: (value) => setState(() => _cashId = value?.id), + ) + else if (_type == QuickSalesPaymentTargetType.bank) + BankAccountComboboxWidget( + businessId: widget.businessId, + selectedAccountId: _bankId, + dense: true, + isRequired: true, + onChanged: (value) => setState(() => _bankId = value?.id), + ) + else + PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: _person, + dense: true, + isRequired: true, + onChanged: (value) => setState(() => _person = value), + ), + if (_type != QuickSalesPaymentTargetType.person) ...[ + const SizedBox(height: 12), + TextField( + controller: _labelController, + decoration: const InputDecoration( + labelText: 'عنوان دکمه (اختیاری)', + border: OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _shortcutController, + textDirection: TextDirection.ltr, + decoration: const InputDecoration( + labelText: 'کلید میانبر (اختیاری)', + hintText: 'مثلاً Alt+1', + border: OutlineInputBorder(), + ), + ), + if (widget.canCreateShared) + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('نمایش برای همه کاربران'), + value: _shared, + onChanged: _saving || widget.existing != null + ? null + : (value) => setState(() => _shared = value), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('ذخیره'), + ), + ], + ); + } +} diff --git a/hesabixUI/hesabix_ui/test/quick_sales_payment_balance_test.dart b/hesabixUI/hesabix_ui/test/quick_sales_payment_balance_test.dart index 54594a46..e6966491 100644 --- a/hesabixUI/hesabix_ui/test/quick_sales_payment_balance_test.dart +++ b/hesabixUI/hesabix_ui/test/quick_sales_payment_balance_test.dart @@ -23,12 +23,7 @@ void main() { group('canAddQuickSalesPaymentLine', () { test('allows a second bank when remaining is already settled', () { final payments = [ - _tx( - id: 'b1', - type: TransactionType.bank, - amount: 800000, - bankId: '10', - ), + _tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'), ]; expect( canAddQuickSalesPaymentLine( @@ -57,7 +52,7 @@ void main() { }); group('applyQuickSalesPaymentAmount', () { - test('splits a full bank receipt into two same-type receipts', () { + test('keeps payment rows independent when adding a second card', () { final payments = [ _tx(id: 'b1', type: TransactionType.bank, amount: 800000, bankId: '10'), _tx(id: 'b2', type: TransactionType.bank, amount: 0, bankId: '11'), @@ -74,17 +69,14 @@ void main() { expect(next, hasLength(2)); expect(next[0].id, 'b1'); expect(next[0].type, TransactionType.bank); - expect(next[0].amount, 500000); + expect(next[0].amount, 800000); expect(next[1].id, 'b2'); expect(next[1].type, TransactionType.bank); expect(next[1].amount, 300000); - expect( - next.fold(0, (sum, p) => sum + p.amount), - 800000, - ); + expect(next.fold(0, (sum, p) => sum + p.amount), 1100000); }); - test('reduces cash when a bank line is typed against a settled sale', () { + test('does not reduce cash when a bank line is entered', () { final payments = [ _tx( id: 'c1', @@ -104,12 +96,12 @@ void main() { ); expect(next[0].type, TransactionType.cashRegister); - expect(next[0].amount, 500000); + expect(next[0].amount, 800000); expect(next[1].type, TransactionType.bank); expect(next[1].amount, 300000); }); - test('drops other lines that fall to zero so bank can replace cash', () { + test('keeps every row when the total reaches overpayment', () { final payments = [ _tx( id: 'c1', @@ -128,12 +120,13 @@ void main() { decimalPlaces: 0, ); - expect(next, hasLength(1)); - expect(next.single.id, 'b1'); - expect(next.single.amount, 800000); + expect(next, hasLength(2)); + expect(next[0].amount, 800000); + expect(next[1].id, 'b1'); + expect(next[1].amount, 800000); }); - test('caps a single line so payments cannot exceed the invoice', () { + test('allows a single payment to exceed the invoice', () { final payments = [ _tx( id: 'c1', @@ -151,7 +144,7 @@ void main() { decimalPlaces: 0, ); - expect(next.single.amount, 800000); + expect(next.single.amount, 900000); }); }); } -- 2.43.0 From aeeeab7e3765f792b6c11cec55d1750abf1d22b2 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 13 Oct 2026 19:52:50 +0330 Subject: [PATCH 3/3] fix(quick-sales): align payment dock layout --- .../lib/pages/business/quick_sales_page.dart | 9 +- .../quick_sales/quick_sales_payment_dock.dart | 111 ++++++++++-------- 2 files changed, 72 insertions(+), 48 deletions(-) diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index aca3dccd..d912093f 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -4366,6 +4366,7 @@ class _QuickSalesPageState extends State children: [ Expanded( child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ستون اصلی: سبد خرید Expanded(flex: 7, child: cartColumn), @@ -5209,6 +5210,7 @@ class _QuickSalesPageState extends State includeCustomer: true, includeDocumentMeta: true, includePayment: false, + includeTotal: false, ), ), ), @@ -5220,6 +5222,7 @@ class _QuickSalesPageState extends State required bool includeCustomer, required bool includeDocumentMeta, bool includePayment = true, + bool includeTotal = true, }) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -5243,8 +5246,10 @@ class _QuickSalesPageState extends State ), const SizedBox(height: 10), ], - _buildCheckoutTotalHero(cs), - const SizedBox(height: 4), + if (includeTotal) ...[ + _buildCheckoutTotalHero(cs), + const SizedBox(height: 4), + ], _buildCheckoutDiscountBlock(cs), const SizedBox(height: 10), if (includePayment) ...[ diff --git a/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart index 9b72ebef..a87fa2c1 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/quick_sales/quick_sales_payment_dock.dart @@ -53,10 +53,10 @@ class QuickSalesPaymentDock extends StatefulWidget { class _QuickSalesPaymentDockState extends State { static const _uuid = Uuid(); - static const double _defaultButtonWidth = 510; - static const double _minButtonWidth = 330; - static const double _minListWidth = 300; - static const double _summaryWidth = 280; + static const double _defaultButtonWidth = 384; + static const double _minButtonWidth = 300; + static const double _minListWidth = 360; + static const double _summaryWidth = 384; static const int _maxPaymentLines = 50; final QuickSalesService _service = QuickSalesService(); @@ -87,7 +87,7 @@ class _QuickSalesPaymentDockState extends State { : 0; String get _preferenceKey => - 'quick_sales_payment_dock_buttons_width_${widget.businessId}_${widget.authStore.currentUserId ?? 0}'; + 'quick_sales_payment_dock_buttons_width_v2_${widget.businessId}_${widget.authStore.currentUserId ?? 0}'; @override void initState() { @@ -306,45 +306,62 @@ class _QuickSalesPaymentDockState extends State { .toDouble(); return SizedBox( height: 220, - child: Row( - textDirection: TextDirection.rtl, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: buttonWidth, child: _buildAmountAndButtons(cs)), - _ResizeHandle( - onReset: () { - setState( - () => _buttonWidth = _defaultButtonWidth - .clamp(_minButtonWidth, maxButtons) - .toDouble(), - ); - unawaited(_saveWidth()); - }, - onDrag: (dx) { - setState(() { - _buttonWidth = (_buttonWidth - dx) - .clamp(_minButtonWidth, maxButtons) - .toDouble(); - }); - }, - onDragEnd: () => unawaited(_saveWidth()), - onNudge: (dx) { - setState(() { - _buttonWidth = (_buttonWidth + dx) - .clamp(_minButtonWidth, maxButtons) - .toDouble(); - }); - unawaited(_saveWidth()); - }, - ), - Expanded(child: _buildPaymentList(cs)), - VerticalDivider( - width: 1, - thickness: 1, - color: cs.outlineVariant, - ), - SizedBox(width: _summaryWidth, child: _buildSummary(cs)), - ], + child: DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: cs.outlineVariant)), + ), + child: Row( + textDirection: TextDirection.rtl, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: buttonWidth, + child: ColoredBox( + color: cs.surfaceContainerLow, + child: _buildAmountAndButtons(cs), + ), + ), + _ResizeHandle( + onReset: () { + setState( + () => _buttonWidth = _defaultButtonWidth + .clamp(_minButtonWidth, maxButtons) + .toDouble(), + ); + unawaited(_saveWidth()); + }, + onDrag: (dx) { + setState(() { + _buttonWidth = (_buttonWidth - dx) + .clamp(_minButtonWidth, maxButtons) + .toDouble(); + }); + }, + onDragEnd: () => unawaited(_saveWidth()), + onNudge: (dx) { + setState(() { + _buttonWidth = (_buttonWidth + dx) + .clamp(_minButtonWidth, maxButtons) + .toDouble(); + }); + unawaited(_saveWidth()); + }, + ), + Expanded(child: _buildPaymentList(cs)), + VerticalDivider( + width: 1, + thickness: 1, + color: cs.outlineVariant, + ), + SizedBox( + width: _summaryWidth, + child: ColoredBox( + color: cs.surfaceContainerLow, + child: _buildSummary(cs), + ), + ), + ], + ), ), ); }, @@ -463,6 +480,7 @@ class _QuickSalesPaymentDockState extends State { decimalPlaces: widget.decimalPlaces, ), enabled: widget.enabled, + textDirection: TextDirection.ltr, textAlign: TextAlign.end, keyboardType: const TextInputType.numberWithOptions( decimal: true, @@ -513,6 +531,7 @@ class _QuickSalesPaymentDockState extends State { controller: _amountController, focusNode: _amountFocus, enabled: widget.enabled, + textDirection: TextDirection.ltr, textAlign: TextAlign.end, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ @@ -561,8 +580,8 @@ class _QuickSalesPaymentDockState extends State { controller: _buttonScroll, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 155, - mainAxisExtent: 72, + maxCrossAxisExtent: 122, + mainAxisExtent: 66, crossAxisSpacing: 8, mainAxisSpacing: 8, ), -- 2.43.0