Compare commits

..
22 Commits
Author SHA1 Message Date
javad 2e5d0bcdd3 some improve 2026-10-27 11:49:09 +03:30
javad fa73a734a0 improve bazdid 6 2026-10-22 14:01:08 +03:30
javad 99c25a8216 improve bazdid 5 2026-10-21 16:34:38 +03:30
javad 825f2a5973 improve bazdid 4 2026-10-21 16:34:13 +03:30
javad 8fd881e0ea improve bazdid 4 2026-10-21 15:13:35 +03:30
javad f86d060e4d improve bazdid 3 2026-10-21 12:24:52 +03:30
javad f8d768a724 improve bazdid 2 2026-10-21 11:28:01 +03:30
javad 534a84f977 improve bazdid 2026-10-21 10:50:42 +03:30
javad 2aee85593f improve entezar bazdid 2026-10-20 19:02:34 +03:30
javad 1fd27d0445 fix mandehesab, bazdid, sms 2026-10-16 17:54:07 +03:30
javad 7e5c83bea2 fix mobile and names 2026-10-15 11:21:55 +03:30
javad e3a6d1f9d4 fix sms 2026-10-11 19:04:20 +03:30
javad 1dea3509a7 fix phone columns 2026-10-10 17:51:30 +03:30
javad 69de29a8fa docker compose edit 5 2026-10-10 16:21:52 +03:30
javad e9ffc11e44 docker compose edit 4 2026-10-10 15:39:54 +03:30
javad b82a2f61a8 docker compose edit 3 2026-10-10 15:29:40 +03:30
javad 0fcb7632cb docker compose edit 2 2026-10-10 15:23:12 +03:30
javad f273c83a5e docker compose edit 2026-10-10 15:01:13 +03:30
javad 5c6069c3ef sms 2026-10-10 14:21:04 +03:30
javad 250cdbab7f refactor 2026-10-09 20:02:36 +03:30
javad 6f36db63a1 before change 2026-10-09 18:12:48 +03:30
javad 91768be145 graphify add 2026-10-09 17:18:17 +03:30
170 changed files with 282934 additions and 4026 deletions

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
# ==========================================
# 1. Operational Style & Communication (Claude Code Style)
# ==========================================
You are an expert engineer. Deliver high-signal, structured technical execution with zero conversational fluff.
## Communication Rules
- Match the user's language (Persian or English).
- Be direct, terse, and technical. Never use pleasantries, greetings, apologies, or robotic affirmations.
- Do NOT output preamble text or explanations before invoking tools; execute immediately.
- Never run interactive or long-running commands (e.g., ensure test commands include non-blocking flags).
## Task Wrap-Up Format
- Minor fixes (single file/few lines): Output a single sentence or "Done."
- Complex or multi-step tasks: Output ONLY this structured summary:
- 1-sentence summary of the fix/feature.
- Bullet points of key modified files and core changes.
- Test/build verification status.
- Required manual actions (e.g., .env keys or migrations), only if strictly necessary.
# ==========================================
# 2. Project Operating Guidelines
# ==========================================
## Codebase Navigation & Analysis (Ripwire vs. Graphify)
- Default to Ripwire for all hands-on coding, AST, edits, call graphs, and tests.
- Reserve Graphify strictly for high-level architectural understanding, cross-artifact relations, and non-code assets.
- CLI-only (never use MCP). Route strictly by question and artifact type.
- Never query both tools for the same sub-task; never execute in parallel. Always prefer `--expand` over reading entire files.
- Trivial edits (<= 1 file, no exported symbols): Modify directly without querying either tool.
- Routing gate:
- Code, symbols, AST, usages, impact, tests -> Ripwire.
- Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), SQL schemas -> Graphify (.sql symbols use Ripwire; schema semantics use Graphify).
- Ambiguous/Mixed -> run `ripwire . --help-task="task"` to classify.
- Graphify rules: Requires `graphify-out/graph.json` (if absent, fallback to Ripwire). Match terms directly against graph vocabulary. Cross-verify graph findings against source code before editing.
- Pre-edit gate: Inspect target symbol with both `ripwire . --impact=SYM` and `ripwire . --uses=SYM`.
- Post-edit pipeline: Run `ripwire . --test-gate` followed by `ripwire . --quality-delta`.
## Dynamic Help, Self-Correction & Staleness
- Help precedence: Inspect `ripwire --help` or `ripwire --help=--FLAG` when uncertain; CLI help is authoritative.
- Retries: Cap at 1 retry on failures.
- Stale data: Run `graphify update .` or `ripwire . --no-cache` and retry once.
- Missing binary: Report missing dependency clearly; do not guess syntax.
## Git Operations & Safety
- Read-only for agent: Allowed commands are strictly `git status`, `git diff`, and `git log`.
- Human-gated: NEVER run `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`, or branch commands. Ask the user to execute these and pause.
- Exception: `graphify hook install` is permitted (read-side only).
## Development Server Lifecycle
- Binding: Always bind Django to `0.0.0.0:8000` (LAN accessible). Never use `127.0.0.1` unless explicitly requested.
- Process management: Inspect port 8000 and terminate previous project server instances before launching.
- Health check: Confirm exactly one process listens on `0.0.0.0:8000`, then verify health via local fetch to `127.0.0.1:8000`.
- Asset verification: Verify served frontend content on live server before diagnosing browser cache issues.
## Environment, Frontend & PWA Safety
- Python venv: Strictly use `.venv` for all Python/Django operations (`.venv/Scripts/python.exe` on WSL, `.venv\Scripts\python.exe` on Windows). Run pip with `<venv-python> -m pip` and Django with `<venv-python> manage.py ...`.
- PyPI Mirror: Use Liara mirror: `https://package-mirror.liara.ir/repository/pypi/simple`.
- Asset versioning: Local frontend assets must include `static_asset_version` in URLs.
- PWA: Keep service worker registration URL versioned with `updateViaCache: 'none'`.
- Cache regression gate: Frontend/PWA cache changes must pass assertions in `tests/test_views.py` and `tests/e2e/test_pwa_versioning.py`.
## End-to-End Browser Tests (Playwright)
- Tests live in `tests/e2e/`, marked with `e2e`, run against Django's transactional `live_server`.
- Use Chromium and BrowserContext from `tests/e2e/conftest.py`. Function-scoped to avoid deadlocks.
- Page Objects: Encapsulate in `tests/e2e/pages/`. Assert user-visible outcomes only.
- Synchronization: Never use hardcoded sleeps; use Playwright locators and assertions.
+21
View File
@@ -0,0 +1,21 @@
---
description: graphify knowledge graph context
alwaysApply: true
---
This project has a graphify knowledge graph at graphify-out/.
**MANDATORY: Before using Read, Grep, Glob, or Bash to explore the codebase, you MUST run graphify first:**
- `graphify query "<question>"` — scoped subgraph for any codebase or architecture question
- `graphify path "<A>" "<B>"` — dependency path between two symbols
- `graphify explain "<concept>"` — all nodes related to a concept
This applies to YOU and to every subagent you spawn. Include this rule explicitly in every subagent prompt that involves code exploration. Do not skip graphify because files are "already known" or because you are executing a plan — the graph surfaces cross-file dependencies and INFERRED edges that grep and Read cannot find.
Only use Read/Grep/Glob directly when:
1. graphify has already oriented you and you need to modify or debug specific lines
2. `graphify-out/graph.json` does not exist yet
- If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files
- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review when query/path/explain do not surface enough context
- After modifying code files, run `graphify update .` to keep the graph current (AST-only, no API cost)
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"BeforeTool": [
{
"matcher": "read_file|list_directory",
"hooks": [
{
"type": "command",
"command": "C:/Users/javad/.local/bin/graphify.EXE hook-guard gemini"
}
]
}
]
}
}
+17
View File
@@ -0,0 +1,17 @@
## graphify
For any question about this repo's architecture, structure, components, or how to add/modify/find
code, your first action should be `graphify query "<question>"` when `graphify-out/graph.json`
exists. Use `graphify path "<A>" "<B>"` for relationship questions and `graphify explain "<concept>"`
for focused-concept questions. These return a scoped subgraph, usually much smaller than the full
report or raw grep output.
Triggers: "how do I…", "where is…", "what does … do", "add/modify a <component>",
"explain the architecture", or anything that depends on how files or classes relate.
If `graphify-out/wiki/index.md` exists, use it for broad navigation. Read `graphify-out/GRAPH_REPORT.md`
only for broad architecture review or when query/path/explain do not surface enough context. Only read
source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or
(c) the graph is missing or stale.
Type `/graphify` in Copilot Chat to build or update the graph.
+9
View File
@@ -6,3 +6,12 @@ data/*.sqlite3
*.sqlite3
*.zip
data9
.venv
.venv/*
# run.sh: pid files و لاگ‌های اجرای محلی
.run/
# graphify: ignore regenerable cache and dated backups, keep the graph itself
graphify-out/cache/
graphify-out/2026-*/
+24
View File
@@ -0,0 +1,24 @@
# graphify ignore rules for the khatibi project
# Third-party static assets (DataTables, jQuery, pdfmake, JSZip, fonts, icons...)
admin_panel/static/
static/
*.min.js
*.min.css
# Generated / data artifacts
graphify-out/
data9/
*.zip
khatibi.zip
db.json
PROJECT_TREE.txt
# Python caches
__pycache__/
*.pyc
# Backups and scratch copies
backups/
admin_panel/views - Copy.py
admin_panel/viewsBK.py
+5
View File
@@ -0,0 +1,5 @@
{
"plugin": [
".opencode/plugins/graphify.js"
]
}
+30
View File
@@ -0,0 +1,30 @@
// graphify OpenCode plugin
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
//
// IMPORTANT: keep the reminder string free of backticks and $(...) constructs.
// The hook prepends `echo "<reminder>" && <cmd>` to the user's bash command;
// backticks inside the double-quoted echo trigger bash command substitution,
// which both corrupts tool output and silently executes the very graphify
// command we are only suggesting. Plain words render fine in opencode's TUI.
import { existsSync } from "fs";
import { join } from "path";
export const GraphifyPlugin = async ({ directory }) => {
let reminded = false;
return {
"tool.execute.before": async (input, output) => {
if (reminded) return;
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (input.tool === "bash") {
// ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
// separator, breaking the first bash command of the session (#1646).
output.args.command =
'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." ; ' +
output.args.command;
reminded = true;
}
},
};
};
+296
View File
@@ -0,0 +1,296 @@
## **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)**
### **فهرست مطالب**
* **مقدمات اتصال** (صفحه ۳)
* **راهنمای استفاده از لینک ارتباطی** (صفحه ۴)
* **کدهای خطای لینک ارتباطی** (صفحه ۵)
* **راهنمای استفاده از وب‌سرویس** (صفحه ۶)
* **کدهای خطای وب‌سرویس** (صفحه ۸)
* **نمونه کدها**:
* نمونه کدهای ASP (صفحه ۹)
* نمونه کدهای PHP (صفحه ۱۱)
* نمونه کد Python (صفحه ۱۴)
### **مقدمات اتصال**
جهت فعال‌سازی سرویس ابتدا وارد حساب کاربری خود در سامانه 0098vip شوید و از منوی سمت راست گزینه «لینک ارتباطی \- وب سرویس (otp)» را انتخاب نمایید:
1. **مرحله اول (ورود به بخش مربوطه):** از منوی سمت راست پنل، بر روی گزینه «لینک ارتباطی \- وب سرویس (otp)» کلیک کنید.
2. **مرحله دوم (انتخاب شماره و فعال‌سازی):** شماره اختصاصی پنل خود را در بخش فعال‌سازی انتخاب کرده و دکمه «فعال‌سازی» را بزنید.
3. **مرحله سوم (انتخاب رمز وب‌سرویس):** در پایین صفحه و در بخش «انتخاب رمز برای لینک ارتباطی \- وب سرویس»، رمز اصلی ورود به پنل را وارد نموده و بر روی «ایجاد رمز وب سرویس» کلیک کنید. سامانه رمزی اختصاصی جهت اتصال به API و وب‌سرویس تولید می‌کند.
> **نکته امنیتی:** پیشنهاد می‌شود از منوی تنظیمات، در بخش «IP های امن»، آدرس آی‌پی سرور خود را درج فرمایید تا ارسال پیامک منحصراً از سمت سرور شما مجاز باشد.
### **راهنمای استفاده از لینک ارتباطی (URL GET)**
آدرس پایه جهت فراخوانی لینک ارسال پیامک به شرح زیر است:
Plaintext
https://0098vip.com/sendsmslink.aspx?FROM=$FROM\&TO=$TO\&TEXT=$TEXT\&USERNAME=$USERNAME\&PASSWORD=$PASSWORD\&DOMAIN=$DOMAIN
پارامترهای متغیر در لینک:
* FROM: شماره اختصاصی فرستنده متصل به لینک
* TO: شماره مقصد (گیرنده پیامک)
* TEXT: متن ارسالی پیامک
* USERNAME: نام کاربری ورود به پنل
* PASSWORD: رمز عبور اتصال
* DOMAIN: عبارت 0098
### **جدول کدهای خطای لینک ارتباطی**
| کد خطا | شرح خطا |
| ----: | ----: |
| ۰ | عملیات با موفقیت به پایان رسید. |
| ۱ | شماره گیرنده اشتباه است. |
| ۲ | گیرنده تعریف نشده است. |
| ۳ | فرستنده تعریف نشده است. |
| ۴ | متن تنظیم نشده است. |
| ۵ | نام کاربری تنظیم نشده است. |
| ۶ | کلمه عبور تنظیم نشده است. |
| ۷ | نام دامین تنظیم نشده است. |
| ۸ | مجوز شما باطل شده است. |
| ۹ | اعتبار پیامک شما کافی نیست. |
| ۱۰ | برای این شماره لینک تعریف نشده است. |
| ۱۱ | عدم مجوز برای اتصال لینک. |
| ۱۲ | نام کاربری و کلمه عبور اشتباه است. |
| ۱۳ | کاراکتر غیر مجاز در متن وجود دارد. |
| ۱۴ | سقف ارسال روزانه پر شده است. |
| ۱۶ | عدم مجوز شماره برای ارسال از لینک. |
| ۱۷ | خطا در شماره پنل، لطفاً با پشتیبانی تماس بگیرید. |
| ۱۸ | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. |
| ۱۹ | تنظیمات کد opt انجام نشده است. |
| ۲۰ | فرمت کد opt صحیح نیست. |
| ۲۱ | تنظیمات کد opt توسط ادمین تایید نشده است. |
| ۲۲ | اطلاعات مالک شماره ثبت و تایید نشده است. |
| ۲۳ | هنوز اجازه ارسال به این شماره پنل داده نشده است. |
| ۲۴ | ارسال از IP غیر مجاز انجام شده است. |
### **راهنمای استفاده از Web Service**
آدرس وب‌سرویس سامانه:
Plaintext
https://webservice.0098vip.com/service.asmx
*نکته:* پس از افزودن ماژول وب‌سرویس، توصیه می‌شود Namespace اولیه را تغییر داده و نامی بدون نقطه (.) تنظیم کنید.
توابع وب‌سرویس:
1. SendSMS: ارسال پیامک تکی به‌صورت آنی.
2. SendSMSWithTime: ارسال پیامک تکی زمان‌بندی‌شده (با قابلیت تعیین تاریخ و زمان).
3. SendSMSWithID: ارسال پیامک تکی همراه با بازگرداندن شناسه پیامک (smsid) جهت رهگیری وضعیت.
4. smsdeliveryState: استعلام وضعیت دلیوری پیامک ارسالی با استفاده از شناسه پیامک.
5. RemainSms: دریافت مانده موجودی پیامک‌های پنل.
### **پارامترهای ورودی توابع وب‌سرویس**
| نام پارامتر | نوع داده | توضیح |
| ----: | ----: | ----: |
| Username | String | نام کاربری پنل |
| password | String | پسورد پنل |
| text | String | متن پیامک |
| MobileNo | String | شماره موبایل گیرنده |
| PnlNo (PniNo) | String | شماره اختصاصی پنل در سامانه |
| Timestmp | String | تاریخ و زمان ارسال در قالب Timestamp (۱۰ رقمی) |
| smsid | String | شناسه پیامک (عدد حداقل ۹ رقمی در فرمت string) |
### **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری**
| کد برگشتی | توضیحات |
| ----: | ----: |
| ۰ | ارسال با موفقیت انجام شد. |
| ۲ (یا عدد ۹+ رقمی) | ارسال با موفقیت انجام شد (خروجی تابع دریافت شناسه/دلیوری). |
| ۴ | مخاطب دریافت پیام‌های تبلیغاتی خود را بسته است (بلک‌لیست). |
| ۵ | رسیده به گوشی. |
| ۶ | نرسیده به گوشی. |
| ۷ | وضعیت دریافت پیامک منقضی شده است. |
| ۸ | وضعیت پیامک نامشخص است. |
| ۹ | اطلاعات مالک شماره ثبت و تایید نشده است. |
| ۱۰ | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. |
| ۱۲ | هنوز اجازه ارسال به این شماره پنل داده نشده است. |
| ۱۳ | ارسال از IP غیر مجاز انجام شده است. |
| ۱۷ | نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی تماس بگیرید. |
| ۱۸ | کاراکتر غیر مجاز در متن وجود دارد. |
| ۱۹ | متن پیامک خالی است. |
| \-۲۲ | خطای شارژ؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-23 | حساب کاربری شما مسدود است؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-24 | مرحله دوم ثبت‌نام شما انجام نگرفته است؛ لطفاً ثبت‌نام را کامل نمایید. |
| \-25 | عدم مجوز شماره برای ارسال از لینک. |
| \-26 | خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-27 | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. |
| \-28 | تنظیمات کد opt انجام نشده است. |
| \-29 | فرمت کد opt صحیح نیست. |
| \-31 | smsid یافت نشد. |
| \-32 | smsid مطابقت ندارد. |
| ۶۶ | شارژ پنل شما برای ارسال کافی نیست؛ لطفاً اقدام به شارژ نمایید. |
| ۱۱۱۱ | شماره موبایل صحیح نیست. |
| Hang | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. |
| No Doc | کاراکتر غیر مجاز در متن وجود دارد. |
| Wrong timestamp | تایم‌استمپ ارسال‌شده صحیح نیست. |
| Wrong time | زمان در بازه صحیح نیست (تنظیم ارسال از همان روز تا ۱ سال بعد مجاز است). |
### **نمونه کدهای پیاده‌سازی**
#### **۱. نمونه کدهای C\# / ASP.NET**
**ارسال پیامک ساده (SendSMS):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string responsecode \= ss.SendSMS(username, password, smstext, mobileno, panelno);
**ارسال پیامک زمان‌دار (SendSMSWithTime):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string timestamp \= "1658939130";
string responsecode \= ss.SendSMSwithtime(username, password, smstext, mobileno, panelno, timestamp);
**ارسال پیامک با دریافت شناسه (SendSMSWithID):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string responsecode \= ss.SendSMSWithID(username, password, smstext, mobileno, panelno);
**استعلام دلیوری (smsdeliveryState):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smsid \= "XXXXXXXXXX";
string deliverystate \= ss.smsdeliveryState(username, password, smsid);
**استعلام مانده اعتبار (RemainSms):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string remain \= ss.RemainSms(username, password);
#### **۲. نمونه کدهای PHP**
**ارسال ساده (SendSMS):**
PHP
\<?php
// Enable extension=php\_soap.dll
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMS($parameters)-\>SendSMSResult;
?\>
**ارسال زمان‌دار (SendSMSWithTime):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['timestamp'\] \= "1658939130";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMSwithtime($parameters)-\>SendSMSWithTimeResult;
?\>
**ارسال با دریافت شناسه (SendSMSWithID):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMSWithID($parameters)-\>SendSMSWithIDResult;
?\>
**استعلام دلیوری (smsdeliveryState):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['smsid'\] \= "XXXXXXXXXX";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>smsdeliveryState($parameters)-\>smsdeliveryStateResult;
?\>
**استعلام مانده اعتبار (RemainSms):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>RemainSms($parameters)-\>remainResult;
?\>
#### **۳. نمونه کد Python**
ارسال ساده با کتابخانه Zeep:
Python
from zeep import Client
client \= Client('https://webservice.0098vip.com/service.asmx?WSDL')
parameters \= {
'username': 'your\_username',
'password': 'your\_password',
'mobileno': '0912XXXXXXX',
'panelno': '3000XXXXXXX',
'text': 'test',
'isflash': False
}
response \= client.service.SendSMS(parameters)
print("SendSMSResult:", response)
**منابع**
1\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
2\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
3\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
+81
View File
@@ -0,0 +1,81 @@
# Project Operating & Agent Guidelines
## 1. Codebase Navigation & Analysis (Ripwire vs. Graphify)
> **Rule of Thumb:** Default to **Ripwire** for all hands-on coding, AST, edits, call graphs, and tests. Reserve **Graphify** strictly for high-level architectural understanding, cross-artifact relations, and non-code assets.
CLI-only (never use MCP). Route strictly by question and artifact type. Never query both tools for the same sub-task; never execute in parallel. Always prefer `--expand` over reading entire files.
| Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) |
|---|---|---|
| **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema |
| **Best at** | Callers, callees, impact, tests, quality delta, `--arch`, `--doc-drift` | Conceptual "How A relates to B", ADR/RFC context, schemas |
| **Primary CLI** | `ripwire . --for="…"`, `--callers=SYM`, `--impact=SYM`, `--test-gate` | `graphify query "…"`, `graphify path "A" "B"`, `graphify explain "node"` |
| **Boundary** | Prose meaning, non-code files, cross-asset concepts | Exact symbol references, impact analysis, code test gates |
### Routing Gate & Pipeline
1. **Trivial edits (≤1 file, no exported symbols):** Modify directly without querying either tool.
2. **Execution routing:**
- Hands-on coding, symbols, AST, usages, impact, and tests → **Ripwire**.
- Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), and SQL schemas → **Graphify**. (Note: `.sql` symbols use Ripwire; schema semantics use Graphify).
- Ambiguous/Mixed tasks → Run `ripwire . --help-task="task"` to classify, then route by artifact.
3. **Graphify query rules (when selected):**
- Requires `graphify-out/graph.json`; if absent, route code questions to Ripwire.
- **Bilingual alignment:** Match terms directly against `graphify-out/graph.json` vocabulary. Prioritize English code identifiers; include Persian terms only when verified in graph documentation/requirements. Never rely on internal translation or stemming.
- **Traversal selection:** Use BFS for broad relationships, DFS for dependency traces, `path` for shortest linkage, and `explain` for node neighbors.
- Always cross-verify critical graph findings against source code before editing.
4. **Pre-edit impact gate:** Before any non-trivial code change, inspect the target symbol with both `ripwire . --impact=SYM` and `ripwire . --uses=SYM`; if either query refuses or is ambiguous, resolve the symbol using the CLI guidance before editing.
5. **Post-edit pipeline:** Run `ripwire . --test-gate` followed by `ripwire . --quality-delta`.
---
## 2. Dynamic Help, Self-Correction & Staleness
Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters.
- **Help Precedence:** Before using an unfamiliar or ambiguous capability, inspect `ripwire --help` or `ripwire --help=--FLAG`; if any instruction or remembered syntax conflicts with that output, the CLI help is authoritative.
- **Dynamic Inspection (on error or flag confusion):**
- Ripwire: `ripwire --help`, `ripwire --help=--FLAG`, `ripwire . --doctor`, `ripwire . --help-task="task"`
- Graphify: `graphify --help`, `graphify <cmd> --help`
- **Fallback & Retries (Cap at 1):**
- If a command fails or rejects flags: inspect its `--help` and retry once.
- If a tool returns empty results or an explicit refusal: try the alternative tool once; if still unresolved, report to user.
- If data is suspected to be stale: run `graphify update .` or `ripwire . --no-cache` and retry once.
- If a tool binary is missing (`command -v` check fails): state missing dependency clearly; do not guess syntax.
---
## 3. Git Operations & Safety
- **Read-only (Agent allowed):** `git status`, `git diff`, `git log`.
- **Human-gated (User execution only):** `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`, and branch management. If needed, request user action and pause.
- **Exception:** `graphify hook install` is permitted (read-side only).
---
## 4. Development Server Lifecycle
- **Binding:** Always bind Django to `0.0.0.0:8000` (LAN accessible). Never use `127.0.0.1` unless explicitly requested.
- **Process Management:** Before launching, inspect port `8000`. Terminate previous project server instances and their child process trees cleanly.
- **Health Verification:** Confirm exactly one process listens on `0.0.0.0:8000`, then fetch `127.0.0.1:8000` locally to verify health.
- **Asset Confirmation:** When checking frontend changes, verify the served content on the live server before suggesting browser cache issues to the user.
---
## 5. Environment, Frontend & PWA Safety
- **Python virtual environment:** Use the repository `.venv` for all Python and Django commands during development, tests, dependency management, and server startup. From WSL, run `.venv/Scripts/python.exe`; from Windows, run `.venv\Scripts\python.exe`. Run pip with `<venv-python> -m pip` and Django with `<venv-python> manage.py ...`. Do not use system Python or pip or create a second environment unless the user requests it.
- **PyPI Mirror:** Use Liara's mirror: `https://package-mirror.liara.ir/repository/pypi/simple`. Use user-level pip configuration. Report downtime before falling back to public PyPI.
- **Asset Versioning:** All locally served frontend assets must include `static_asset_version` in their URLs.
- **PWA Lifecycle:** Keep the service worker registration URL versioned with `updateViaCache: 'none'`.
- **Cache Regression Gate:** Changes to frontend caching or PWA code must pass assertions in `tests/test_views.py` and `tests/e2e/test_pwa_versioning.py`.
---
## 6. End-to-End Browser Tests (Playwright)
- **Organization:** Place tests in `tests/e2e/`, mark with `e2e`, and run against Django's transactional `live_server`.
- **Scope & Lifecycle:** Use managed Chromium and BrowserContext from `tests/e2e/conftest.py`. Keep Playwright function-scoped to prevent event loop deadlocks with Django's synchronous database setup.
- **Page Objects:** Encapsulate interactions inside `tests/e2e/pages/`. Tests must assert user-visible outcomes only.
- **Synchronization:** Never use hardcoded sleeps. Rely on Playwright locators, `expect`, navigation, and state-change assertions.
- **Diagnostics:** Maintain automatic screenshot/trace capture on failure. PWA scenarios must enable service workers and use the `pwa` marker.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+6
View File
@@ -56,6 +56,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# کپی کردن و واگذاری مالکیت محیط مجازی به کاربر django
COPY --from=builder --chown=django:django /opt/venv /opt/venv
# کپی صریح فایل دادهٔ نام‌های ایرانی (پیش‌نیاز دستور fix_customer_names).
# صریح نوشته شده تا حتی اگر context ساخت یا .dockerignore در آینده عوض شود،
# فایل بی‌سروصدا از ایمیج بیرون نیفتد. اگر فایل در context نباشد، build با خطای
# روشن شکست می‌خورد (fail-fast) — بهتر از اجرای دستور روی سرور و خطای «فایل پیدا نشد».
COPY --chown=django:django iranianNamesDataset.json /app/iranianNamesDataset.json
# کپی کردن تمام کدهای پروژه با مالکیت کاربر محدود شده
COPY --chown=django:django . .
+9
View File
@@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+884
View File
@@ -0,0 +1,884 @@
Project Root: khatibi
Generated At: 2026-10-27T07:48:20+00:00
Python Files: 194
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 59
Total Files: 794
Total Directories: 81
khatibi
├── .cursor
│ └── rules
│ └── graphify.mdc
├── .gemini
│ └── settings.json
├── .github
│ └── copilot-instructions.md
├── .opencode
│ ├── plugins
│ │ └── graphify.js
│ └── opencode.json
├── .run
│ ├── delivery.pid
│ ├── scanner.pid
│ ├── web.pid
│ └── worker.pid
├── admin
│ ├── migrations
│ │ └── __init__.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── admin_panel
│ ├── management
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── cleanup_visit_dedup.py
│ │ │ ├── fix_customer_names.py
│ │ │ ├── fix_customer_phones.py
│ │ │ ├── gt_esh.py
│ │ │ ├── normalize_customer_sms_phones.py
│ │ │ ├── recalc_last_bazdid.py
│ │ │ ├── send_daily_report.py
│ │ │ ├── sms_delivery.py
│ │ │ ├── sms_scan.py
│ │ │ ├── sms_worker.py
│ │ │ └── sqlite_db_backup.py
│ │ └── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_auto_20220114_1616.py
│ │ ├── 0003_auto_20220115_1401.py
│ │ ├── 0004_factor_paytype.py
│ │ ├── 0005_auto_20220121_2054.py
│ │ ├── 0006_auto_20220121_2100.py
│ │ ├── 0007_productinstore_factor.py
│ │ ├── 0008_storeroom_user.py
│ │ ├── 0009_remove_storeroom_user.py
│ │ ├── 0010_auto_20220122_1546.py
│ │ ├── 0011_storeroom_user.py
│ │ ├── 0012_visitqueue_user.py
│ │ ├── 0013_alter_factor_options.py
│ │ ├── 0014_auto_20220123_1348.py
│ │ ├── 0015_auto_20220124_0858.py
│ │ ├── 0016_factor_user.py
│ │ ├── 0017_factoriteam_garantyesh.py
│ │ ├── 0018_alter_factoriteam_product.py
│ │ ├── 0019_article_articletype.py
│ │ ├── 0020_auto_20220126_1532.py
│ │ ├── 0021_alter_article_type.py
│ │ ├── 0022_article_typepy.py
│ │ ├── 0023_alter_article_typepy.py
│ │ ├── 0024_action.py
│ │ ├── 0025_action_actiontype.py
│ │ ├── 0026_auto_20220209_1924.py
│ │ ├── 0027_alter_action_nazar.py
│ │ ├── 0028_action_grade.py
│ │ ├── 0029_alter_article_options.py
│ │ ├── 0030_auto_20220414_1047.py
│ │ ├── 0031_article_sarresid.py
│ │ ├── 0032_article_paystatus.py
│ │ ├── 0033_article_nahvepardakht.py
│ │ ├── 0034_alter_article_sarresid.py
│ │ ├── 0035_alter_article_sarresid.py
│ │ ├── 0036_sarresid.py
│ │ ├── 0037_sarresid_sarresiddt.py
│ │ ├── 0038_auto_20240213_1735.py
│ │ ├── 0039_customer_last_bazdid_dt.py
│ │ ├── 0040_customer_laghv_bazdid.py
│ │ ├── 0041_auto_20240216_1607.py
│ │ ├── 0042_article_user.py
│ │ ├── 0043_sarresid_user.py
│ │ ├── 0044_sarresid_desc.py
│ │ ├── 0045_auto_20260610_1421.py
│ │ ├── 0046_auto_20260624_1818.py
│ │ ├── 0047_alter_productinstore_movement_type.py
│ │ ├── 0048_alter_productinstore_options.py
│ │ ├── 0049_alter_productinstore_options.py
│ │ ├── 0050_storetransferrequest.py
│ │ ├── 0051_auto_20260705_1356.py
│ │ ├── 0052_storeroomowner.py
│ │ ├── 0053_storeroom_owners.py
│ │ ├── 0054_auto_20260715_2250.py
│ │ ├── 0055_action_nextnazardt.py
│ │ ├── 0056_auto_20260731_1824.py
│ │ ├── 0057_sms.py
│ │ ├── 0058_seed_sms_templates.py
│ │ ├── 0059_sms_flow_control.py
│ │ ├── 0060_sms_permission_label.py
│ │ ├── 0061_sms_timing_and_primary.py
│ │ ├── 0062_sms_link_provider.py
│ │ ├── 0063_alter_smsqueue_status.py
│ │ ├── 0064_sms_webservice_delivery.py
│ │ ├── 0065_smsproviderconfig_test_message.py
│ │ ├── 0066_smsdedup.py
│ │ ├── 0067_customer_notes.py
│ │ ├── 0068_auto_20261010_1305.py
│ │ ├── 0069_smstemplate_auto_send.py
│ │ ├── 0070_sms_types_seven.py
│ │ ├── 0071_sms_settings_permission.py
│ │ ├── 0072_customernamefixjob.py
│ │ ├── 0073_alter_action_actiontype_choices.py
│ │ ├── 0074_alter_action_status_choices.py
│ │ ├── 0075_cheque_funding_before_due.py
│ │ ├── 0076_alter_smstemplate_text_help.py
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
│ │ ├── 0078_action_visit_covering_index.py
│ │ ├── 0079_customer_visit_hold.py
│ │ ├── 0080_action_soft_delete.py
│ │ ├── 0081_sms_type_install_created.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── actions.py
│ │ ├── customers.py
│ │ ├── factors.py
│ │ ├── finance.py
│ │ ├── products.py
│ │ ├── sms.py
│ │ ├── store.py
│ │ ├── system.py
│ │ └── users.py
│ ├── sms
│ │ ├── __init__.py
│ │ ├── balance.py
│ │ ├── enqueue.py
│ │ ├── notices.py
│ │ ├── parts.py
│ │ ├── phone_cleanup.py
│ │ ├── phones.py
│ │ ├── recipients.py
│ │ ├── render.py
│ │ ├── rules.py
│ │ ├── scanner.py
│ │ ├── timing.py
│ │ └── triggers.py
│ ├── static
│ │ └── admin_panel
│ │ ├── DataTables
│ │ │ ├── AutoFill-2.3.7
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── Buttons-2.1.1
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── DataTables-1.11.3
│ │ │ │ ├── css
│ │ │ │ ├── images
│ │ │ │ └── js
│ │ │ ├── FixedColumns-4.0.1
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── FixedHeader-3.2.1
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── jQuery-3.6.0
│ │ │ │ ├── jquery-3.6.0.js
│ │ │ │ └── jquery-3.6.0.min.js
│ │ │ ├── JSZip-2.5.0
│ │ │ │ ├── jszip.js
│ │ │ │ └── jszip.min.js
│ │ │ ├── pdfmake-0.1.36
│ │ │ │ ├── pdfmake.js
│ │ │ │ ├── pdfmake.min.js
│ │ │ │ └── vfs_fonts.js
│ │ │ ├── Responsive-2.2.9
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── SearchBuilder-1.3.0
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── SearchPanes-1.4.0
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── Select-1.3.4
│ │ │ │ ├── css
│ │ │ │ └── js
│ │ │ ├── datatables.css
│ │ │ ├── datatables.js
│ │ │ ├── datatables.min.css
│ │ │ └── datatables.min.js
│ │ ├── fonts
│ │ │ ├── woff
│ │ │ │ ├── IRANSansX-Bold.woff
│ │ │ │ ├── IRANSansX-Regular.woff
│ │ │ │ ├── IRANSansXFaNum-Bold.woff
│ │ │ │ └── IRANSansXFaNum-Regular.woff
│ │ │ └── woff2
│ │ │ ├── IRANSansX-Bold.woff2
│ │ │ ├── IRANSansX-Regular.woff2
│ │ │ ├── IRANSansXFaNum-Bold.woff2
│ │ │ └── IRANSansXFaNum-Regular.woff2
│ │ ├── icons
│ │ │ ├── add.png
│ │ │ ├── cancell.png
│ │ │ ├── check.png
│ │ │ ├── check1.png
│ │ │ ├── check2.png
│ │ │ ├── check3.png
│ │ │ ├── check43.png
│ │ │ ├── delet.png
│ │ │ ├── edit.png
│ │ │ ├── icons8-checked-checkbox-64.png
│ │ │ ├── time1.png
│ │ │ └── time2.png
│ │ ├── bulma-rtl.min.css
│ │ ├── bulma.min.css
│ │ ├── fontiran.css
│ │ ├── kamadatepicker.holidays.js
│ │ ├── kamadatepicker.min.css
│ │ ├── kamadatepicker.min.js
│ │ ├── persian-date.min.js
│ │ ├── persian-datepicker.min.css
│ │ ├── persian-datepicker.min.js
│ │ └── style.css
│ ├── templates
│ │ └── admin_panel
│ │ ├── customer
│ │ │ ├── customerNazarInc.html
│ │ │ └── nazar.html
│ │ ├── _customer_info.html
│ │ ├── _customer_phones.html
│ │ ├── ActionList.html
│ │ ├── base.html
│ │ ├── baseHesabdari.html
│ │ ├── baseStore.html
│ │ ├── bedehi.html
│ │ ├── bedehiBK.html
│ │ ├── customerAction.html
│ │ ├── customerActionInc.html
│ │ ├── customerBase.html
│ │ ├── customerBaseNew.html
│ │ ├── customerFactor.html
│ │ ├── customerFactorBackup.html
│ │ ├── customerHesabDari.html
│ │ ├── customerID.html
│ │ ├── customerIDBackup.html
│ │ ├── customerIDFactor.html
│ │ ├── customerIDFactorBackup.html
│ │ ├── customerNew.html
│ │ ├── customerNew2.html
│ │ ├── customerNewBackup.html
│ │ ├── customerRezayat.html
│ │ ├── customerRezayatinc.html
│ │ ├── customers.html
│ │ ├── customerSarresid.html
│ │ ├── customersBK.html
│ │ ├── customerSmsHistory.html
│ │ ├── entezar.html
│ │ ├── entezarBK.html
│ │ ├── financial_dashboard.html
│ │ ├── hesabdari.html
│ │ ├── hesabdariCheckPass.html
│ │ ├── hesabdariCheckPassFail.html
│ │ ├── hesabdariResidNashode.html
│ │ ├── index.html
│ │ ├── login.html
│ │ ├── newOperation.html
│ │ ├── operations.html
│ │ ├── operationsBK.html
│ │ ├── report.html
│ │ ├── report_debtors_no_vosol.html
│ │ ├── report_inventory_movements.html
│ │ ├── report_rezayat.html
│ │ ├── reportCheckNesiye.html
│ │ ├── rezayat.html
│ │ ├── sms_queue.html
│ │ ├── sms_report.html
│ │ ├── sms_settings.html
│ │ ├── sms_templates.html
│ │ ├── store.html
│ │ ├── store_transfers.html
│ │ ├── storeBK1.html
│ │ └── stores.html
│ ├── templatetags
│ │ ├── __init__.py
│ │ ├── custom_tags.py
│ │ └── remove_q_p.py
│ ├── views
│ │ ├── __init__.py
│ │ ├── _helpers.py
│ │ ├── actions.py
│ │ ├── auth.py
│ │ ├── cheque_inquiry.py
│ │ ├── customers.py
│ │ ├── factors.py
│ │ ├── finance.py
│ │ ├── sms.py
│ │ └── store.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── context_processor.py
│ ├── name_fix.py
│ ├── services.py
│ ├── signals.py
│ ├── tests.py
│ ├── urls.py
│ ├── views - Copy.py
│ ├── viewsBK.py
│ └── visit_policy.py
├── data9
│ ├── backup-20261020-180934.sqlite3
│ ├── backup-20261020-213406.sqlite3
│ ├── backup-20261020-213406.sqlite3-shm
│ ├── backup-20261020-213406.sqlite3-wal
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
│ ├── db222.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-wal
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
│ └── kt.sqlite3
├── docs
│ ├── entezar_action_fields_report.html
│ ├── entezar_action_fields_report.md
│ └── visit_hold_entezar.md
├── graphify-out
│ ├── 2026-10-09
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-10
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-11
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-16
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-20
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-22
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.82-s7
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
│ │ │ ├── 0175e34128b5ac5119a5da4cc53d889dce334d8f20357631d54627c53cae9b8e.json
│ │ │ ├── 01850b7fad89dcb9e521fceed2c41e64e9909a0f4c4a54d818d346ccf63bbc5d.json
│ │ │ ├── 01b87542be8c5f2850a08a631287f94a89184e186d5cfca4d585800f41ee0f5d.json
│ │ │ ├── 0317da4b3f4f0b0b5f6cf487567ec7e1b6f3bed2c6ad5cb19bd526a020521c10.json
│ │ │ ├── 0348dd4662ebd96572421864ce77a266334a327f8bcbdc237d54c313d116bafb.json
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
│ │ │ ├── 0400cc0eadd706ff70ef627ffe39f401fc72a591dcbcd5463fca07fc271f080d.json
│ │ │ ├── 0442dd7e8445ba796e02aaefde16760c8ed37f63359d4319498be512f60266bd.json
│ │ │ ├── 056f0cb55bfd5a1ddf744c7c824c342048c1db47f3bb350030a1533ab56bf8d7.json
│ │ │ ├── 05b753e15288fbd02b721b00cfd03c3a523e9e234420330b980a462a1de2dad6.json
│ │ │ ├── 07eb6ef411f8e9b78d19cfd5f262bfc9fc482701f0834c8936cbb5ba2b14efab.json
│ │ │ ├── 0b48631fc75356bef276f694d7db5b457bd2315410fd4538680612fe10b95eb2.json
│ │ │ ├── 0b8f8e3b6e4d84256e784dabb96f5646fa6e7295adbd20648c235e7821b4ca1d.json
│ │ │ ├── 0e6bfa570caca1f79f549f5cdc37f8bdf9c9b43a023ca28a8fcb83351389b399.json
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
│ │ │ ├── 10615f5a999e04257afc03cdc52e1b9e4e11ae7818d7c64ae13b976e3974feb4.json
│ │ │ ├── 11042a6b38137d4e725c18d536cde9c98652782cb58390aa6216dacad2d18cf5.json
│ │ │ ├── 1219157d5f2c0a61d4069d2e7067d2eaf1c6403fcd5965743d9a196fe4564184.json
│ │ │ ├── 1285cdf9b89689e3ac99cea19470a3c14fdbf3a9d1bb2cd8da2cfdbd42ca940f.json
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
│ │ │ ├── 13a39530fbb076496d510363b40bf16093d8d117751e7240bcc09a447bdace35.json
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
│ │ │ ├── 143902d3d7280d781e9f6a8e366068809dec67bd385462175012f9b5c072e470.json
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
│ │ │ ├── 17850e40dfa0d02ec3b0d4b2e1f9b931fe6a3a90c6cd874dabeb9466bed5f1d1.json
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
│ │ │ ├── 1b244c373e08c304261f7f760687c7ebe0ba9c79e7ab7ea16c3fa4768da959f5.json
│ │ │ ├── 1ceebfc94adbc38aa951c83044b67943bc4032309ba3a61d9335dd833d49bbef.json
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
│ │ │ ├── 1d7a28f73bb52bbc018674a0c01405b7f395bcc5f340ecabe332e2fe440bcae8.json
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
│ │ │ ├── 1dd0c6958a307a1e4e59c24f20225292277585dd8652b35537ede94fc04807ee.json
│ │ │ ├── 1f7001563689c4ba77d24f98418fd10725419d45385094fd18d89f20573d6453.json
│ │ │ ├── 20652a8d348bf514ac266f30a6400f923674dae819d18046d008066943377804.json
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
│ │ │ ├── 23c92d4714bc2e410ae9267e6ea7c139d5b16215da917f9a89c44a29724c69d7.json
│ │ │ ├── 23da13b308596ede7678e92e57041bcecdd8e9baff04b759ddf9a69803dd790a.json
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
│ │ │ ├── 24508e7a9b6fa93cdd28b9436bdb55fa24ad8f324baa95adb3afd37e301af9ac.json
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
│ │ │ ├── 28b192ecdcd9b33d8e4980d5043b6b2bc59095df392e22bf44d2166f7fa6d5ba.json
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
│ │ │ ├── 2976c530804df2075019fedaf2b908eeeff37b2703310c7e031b653d1fec71e5.json
│ │ │ ├── 2c587e304ae13b0717d72f5099508220c82234a8a293219c2ed2c97df9152404.json
│ │ │ ├── 2f36d1565911fdcb414da716162085b0fdc4b92d9d2d3fbb9fecc403b1916766.json
│ │ │ ├── 3089c169c5eb18e61b13a19751166813d18c7bbe35b938b2b149406ff349feda.json
│ │ │ ├── 3258762ed4d4077750967504aecb42df00c35a69571f5d4839d4cfebd9b1c838.json
│ │ │ ├── 3330ae8555dab8f7ff4e014183f014681feb3f40a0d8969f30a4b2e9658e27f8.json
│ │ │ ├── 338e7d5d2e7d3a7260ab74d9b7b19bbab6b9ce84e7036623060190d9e0cc4bcd.json
│ │ │ ├── 33b32e27be802f61375110f7f4ac9419fd4eb633523e01e31774b874448849c1.json
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
│ │ │ ├── 370c10e9d6f01aa67799b1f2191d5749a69ce4541aa55cbf4a28d45732bd641b.json
│ │ │ ├── 373e16b6c6996757d88a6ac751cd44028efa64701d746f8536819889417125ff.json
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
│ │ │ ├── 3a341a98aef632503bf2dc67ad87833e604c9aabe7ff69f5bcff7b54d6fe6407.json
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
│ │ │ ├── 4162977f2c2251e7a8b331d7229bd43dff56df31356191b19886b5bf5d56377d.json
│ │ │ ├── 41952e7c846da15fe658603ff001dafdb0e12d3372a7f0306adfa3a71de8bee7.json
│ │ │ ├── 4204828f3cbb3465c469e28345e0fc4d7ff9a84e30e3dd9f00ab4dbe27014e0d.json
│ │ │ ├── 435b8e433cd2f09f7b81717214592f9bfadd8edd8177adcabd4338ce803855d2.json
│ │ │ ├── 44fd8135217bc689f0886e5bd75458999e67ea57252445884c2822fff306312e.json
│ │ │ ├── 458c1b9a3ce257e789f75305939ab2e79029c557657102eebf2082e50a47a0e5.json
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
│ │ │ ├── 4723c76012231305253c473fb5f2b81fa35a49f9f4aa38f54e9af3285aa51a02.json
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
│ │ │ ├── 4e24d52f79dfa76aa2406de05583fa49ce966a28fb653fa284fb8e8204797385.json
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
│ │ │ ├── 506e815cbeaaf3aa6e448dcbf70c10b4d118dff41e05a292fb8ec6d76dd391c4.json
│ │ │ ├── 507e42a0bbe7a582ce1fcb77651e1cfde643907ca0429fedfe11afe7ae3b11c4.json
│ │ │ ├── 50b38b9cbcb9d5aad3a94b28cb983b3694926bc20e0381419b82008efd042d65.json
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
│ │ │ ├── 53bcc38a002a291b8a370a1c24dde42e704cbda5d409d77b8405599e45844cae.json
│ │ │ ├── 559fbbe72061ed129b192884f1f2b9c58d23f2af199cd408aa95d7c5af2753ef.json
│ │ │ ├── 5730d72cc177474584197921cce656696e3177780028c4629950f5514b530c6d.json
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
│ │ │ ├── 58bfae9cf8467d2bb9363884d517e98cae38a934f4c474e9e02aed520fdaab95.json
│ │ │ ├── 58f2e3419fb2de4a3bdc4a9c0bf48e50f2cc225506096109dbf2b9bfe4778bb5.json
│ │ │ ├── 5901400fc36893a9ad85acd97eb73b691cb4b8d1440047c5f85c1534a2c7e492.json
│ │ │ ├── 59dd2e5089bebfb0397e9c492c890c619fcaa4d73b9d035e533320f276e1ebf9.json
│ │ │ ├── 5a15a341e370fb512c0b3f5ae1c30ec0d8a6729a690f286369015d107210124e.json
│ │ │ ├── 5b59421d9713dc21f9f77603d9708edd8c3eb8ba198636f80f1cedcc79ffcaca.json
│ │ │ ├── 5b63b5d7fc7a09b2dbf5e2f7db6fe00ccab773762b3291bd666c8e29d32100d3.json
│ │ │ ├── 5c8593cf08ce32f0c569aacfaf83b8baeb352934f6f8749aa4f652d5ce2acff1.json
│ │ │ ├── 5c922734de6f6345b5230507bee5c43dc284c308901e84846666b939493e6258.json
│ │ │ ├── 5ce9ee869c507986bf00ae6929f3aae3ab7aa58b5c2bded4965374fcf7f5654b.json
│ │ │ ├── 5d5b6249e3cfa0aeee94ba2053fc73b4b7b5b8fa1d21df09d40bda7c90ef273d.json
│ │ │ ├── 5d994876cad71133f4bf804932fb2604ffdc8bb8cc434ca1434667ef789144e7.json
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
│ │ │ ├── 5fe315e6417b8eb8b26079139d8bbe46ddaaf7cd67b078d927ea79d3815be453.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
│ │ │ ├── 611c158815bdf3dbec9f5a5c01bce751fcd07db536e9bbaba2b2d232f82f5ae6.json
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
│ │ │ ├── 64fc04d8c553b2121a71955d48257160a40f6dd34258e3287945873f02d897dc.json
│ │ │ ├── 65d5139792e0ee840c5b1710d7659ecfb5dbdcadbd02d205f7517a6a3a374211.json
│ │ │ ├── 6603c5eb7403f7426c429341d43018fdffcb3430f22972c426ac43956d229b33.json
│ │ │ ├── 6648bc2b25d0bd25846d48f406d3e9a6f89677542600e9f3f57533747ed9b08c.json
│ │ │ ├── 6691dea01045e5561ac1c72f902f96ba84564c221498829443f793fee8784021.json
│ │ │ ├── 67b4e119f02b3ed94553e526bbd33cddff90053edb27438e472409b2ca5ad9a0.json
│ │ │ ├── 6805914035b171d32a1b80948179da15f78c5741e5a80b216d51a6586185c0c3.json
│ │ │ ├── 6876f75a9cb4a5f5857c47e3b0d6b38e51941aff76bc6d92cb1b4eef1c51071e.json
│ │ │ ├── 6a88f82e5b9f9da12b50f8531eac30c10e1c2a0cf39601d275fd90ee3e28da55.json
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
│ │ │ ├── 6afa6136d9df8317bdd8d49f4109412776deb21ba123fdd2a97331b400a0230d.json
│ │ │ ├── 6bfbb9eed6842bf331dec78724d7e740919c76345b54be844964f44640482a87.json
│ │ │ ├── 6d026e901d3b2ff0cb9550c750966905afbbc681ba68db88e1de608fb2964495.json
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
│ │ │ ├── 6d556671b5632e29972dc09e345d68d522a5b3bcd2852b803d56668890fa46d7.json
│ │ │ ├── 6dcafcac3a0b169a08580cc4cb9196423dacfa51329841311665c363a97bc29e.json
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
│ │ │ ├── 6ed362dbf43e46eeb440246d52fdc4e0783c895114249ea33f3de7ddf6898a3a.json
│ │ │ ├── 6ee8ac22fd8b7d0152fe85c630c98fd9c7f1f74312b5f923dbebb3ff5367a71e.json
│ │ │ ├── 71a07328d56403d55714d76cefc05db3e68c6498719e629e4e76889a1940efc2.json
│ │ │ ├── 72577fde1eae8cf079aff518cecce910ac310cefcc038fbbcb0a5d11f20fa20d.json
│ │ │ ├── 73a129efd08cc38274f3c56503c234eef17f58faf74dadbdbbe475efcbfc2777.json
│ │ │ ├── 7483cdf00a2d7de8809278d9dcdf2f2330354b02117952da4b9452d4bbd6b81e.json
│ │ │ ├── 74efb913c62b374d818cfc5af6d7ee9bb46966eb2bce0e26e0bba461683b98a7.json
│ │ │ ├── 75d0862c4a7be01c37a92f0baf858c27367f6b242254080cccc5888a758dd0ff.json
│ │ │ ├── 760b5e6a8ce6ddc3b34aae59a11c57c36471f9eb93f8d2e24c7a030c8ad02dbc.json
│ │ │ ├── 761cb648c1272d981720580035fca3a501c423ab819648863f223b83292b4e58.json
│ │ │ ├── 767dd58146a5d4a713611561a9e5c1b706e5f509a5621891c9b2e195f3b8ee0a.json
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
│ │ │ ├── 78d30d0a40b28969d64653d2508fd53e53709b95a45f3e380ebd6c7aff04ace9.json
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
│ │ │ ├── 7d106c54b917d9e26357fa71aa871e94cb99c940b6c48808e93cd57415da91e3.json
│ │ │ ├── 7d59da18a8b644e48a164eb8a764efc9f4c3538b99d41c8e93ce38cc5556ba05.json
│ │ │ ├── 7e9bad82d0eb642b739dcf11e86c2fcf2005dfb12c94478af143deb9a8129bfa.json
│ │ │ ├── 8055bf9fe36a9fa183d0c5dc152684e65410d58c5634e544e8f38f6c9216f6ad.json
│ │ │ ├── 81d6d00c4c975b3289ac212d355856c3d6e60bccde0780fe2e3fc8cac31f3c19.json
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
│ │ │ ├── 82916053b0e3cfa286347ea0a7101f2d06678bd28b8b8d8fe955d11bcb7a43aa.json
│ │ │ ├── 837801d7d0e218e49de562a7e03711e75941d42ce5cc2f77552bc6293a45a4e8.json
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
│ │ │ ├── 84cef5656079ececae35930e1b26620186b50f25fd977c569537daad2d59db86.json
│ │ │ ├── 86e57806cd08cf765ce1f56f0638dd321e93c653bb6613aa35d7ad375808433c.json
│ │ │ ├── 881adf7185e68d203906537117de576a11fba814941cf9eeedd0dfac2d65e536.json
│ │ │ ├── 88e349e879d601e1f5c75f3b019ffb4958e90639317ba893c54f82b0cc9066c2.json
│ │ │ ├── 8a70e1324d7f834d2c41a7c3b4cbb3b8ccad92d413970316bc95e13f5132ff76.json
│ │ │ ├── 8d658a3a8b1ae03cbc881e3c47cf2cfe111f9be2b564fb3739cf3d9367845a8b.json
│ │ │ ├── 8ffb83ce6f13d2f97d0197c7381748e9b66956c13f0158bc519350b7dd1b9ad0.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 909129924b48dede801eb946cf98bb93003ab8116be2b6acbd2f7fe8dc166358.json
│ │ │ ├── 92ecdeb228d91f4adb6c1e9bfd1b3f0eea65faec8dbcb27fca3211d1d264cb46.json
│ │ │ ├── 940d0349821b0fb7f2c3effcb81fd13bb1c046daa6f6c5f2cd1b157058c55877.json
│ │ │ ├── 957f37be1d5d6a5207f90ad07704f8423c915c5bcdfcd130acc85a388fc89d19.json
│ │ │ ├── 95e88ece49092778e7709f6b87db4c05dd055b838cfdf20d7d4705b6373739ba.json
│ │ │ ├── 96c3ba545db191ae5811d5dfdde74e7c6396a932b759513c94160cb7b7c81fc8.json
│ │ │ ├── 96f5469edd566b92d9d38423a27779142fbe2b77cfc9d85005d43ffc99ce3de1.json
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 9b2fd03f2769231e4c207b2f0ee915e3b5eb050232f2fe6c59ddb01904976650.json
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
│ │ │ ├── 9ef7cb42412a83490d3e54c81d4e7fa58db25253eb50a41d97d5d4064df0dfe2.json
│ │ │ ├── a186e74ebd87e23212ffe7fd72c40e9383c0624a3efdb2fe1e3271d9ea96d9b9.json
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
│ │ │ ├── a43d68ff03b49e9153a73777c0bde346a6b1969f640d67eae500d60d410a02c0.json
│ │ │ ├── a7c605f57dd37c8725a92f08d4fe18701628701fa986cbb98d3a4aeb3a74abbe.json
│ │ │ ├── a823c9c28027c8d525714e6571bc3f3ca38f3bd0420aaf4f4921067cf0672475.json
│ │ │ ├── a8311f6e51027ea4606e35bf14d50edc1795e71b2d63cddeae8c7bb7ece215b8.json
│ │ │ ├── aabf6dbb2202699bb1b06fd582c88465b9cf2b8cc060bda2e2bc0c67fc493bed.json
│ │ │ ├── ab5d0682afc0714c24b9ac5fda36b243c638d272ea973f46a5c18e783132c861.json
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
│ │ │ ├── adda28366431a09f735c806811acad036f33199438c7679d275e3c80714b9a80.json
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
│ │ │ ├── af128ca7fc8d02ae40fff36925a5b96034862ce0d581e22a8a0c88f41040e526.json
│ │ │ ├── b24556e20d540fd63de3dc04501ffd021836b930c4ebc3ff309f7950ef17a335.json
│ │ │ ├── b26c22033963df1727407c1fa6c79d8241bfc16056e7a8d3c4383fb2d050d3cd.json
│ │ │ ├── b26e251bde84a040dbb2d1a4f700ebcd761f51a124a98a104704e82537e320eb.json
│ │ │ ├── b34cb0a1a28e753a900b3d23f01d3810f9c06635b0650d34be4ec9d98daa8fbb.json
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
│ │ │ ├── b9b878bef7c0ae7d407a42c797a4697990237971be65a5d01a6c7b9b444a2a9a.json
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
│ │ │ ├── bc24ddb05a8aa685edc1376cbd110423adf05bcc488fb97a30fe286e7e74c2da.json
│ │ │ ├── bc6f93c7938b3565d50cdfc4abe4fd62f0739fd6299f35d7c50075cd860cab58.json
│ │ │ ├── bcea7c584e5031a9c5734144f8169d016ef1ed316e6f4827609509ba2a79d3eb.json
│ │ │ ├── bd00f0b0db2eb10f7e889df5a0f08994e8c1eaa0eb69be8398e45e0e634724fd.json
│ │ │ ├── bd6e156d8e427b14676b4707710f2b340026e679cb7bd29b22b247a4b637e7bd.json
│ │ │ ├── be1200d7c9b2881ba54c7fa95daf5f3e2ce993619b974c7760a9d44a416e00c0.json
│ │ │ ├── be72e639c12e860a2120c3ecf96b94c86e5c69b2f1f0715cd2804d0475409cd6.json
│ │ │ ├── c12baf851ec40542280cb62d2f878d5e7383357365281938cfb55a64c08a9a10.json
│ │ │ ├── c36b696b57c1deb51e47a057b32d1dd75e3f1560e83931aab1f8ba86abcc1543.json
│ │ │ ├── c3c6651e6b7dd802c31efb56ac20c1f1ba3c4c0108599f1480c6a143b2e688ef.json
│ │ │ ├── c46ad3fc8fd3db1315ab2e40add760b44e8f98c24c79e334c342c6a411fc671c.json
│ │ │ ├── c52300dce4cf464158f8db56df5030359a501a81bbe4a5f36e50c8c38a5ac48c.json
│ │ │ ├── c535c8a7523b768d28903b84f4cfbce7d62181a2a05f3db08ec3a7c8cbf86ce8.json
│ │ │ ├── c56beb43cf063fd1cc92950ca14695b543b3971bf7e7dbd4a676242057258533.json
│ │ │ ├── c5c93ccc7715c31fc7538bcd9327c8fb7c1e495bd17d6444b384ec426b7a231f.json
│ │ │ ├── c718a8f0cdfb66217ab4484dca103c7029d43976f7f0889bfb11f6cab3bb2510.json
│ │ │ ├── c72226544540cc75217ba02f1d09d989eb810d879409ac086c76245f27220cfa.json
│ │ │ ├── c795f43bd1f10cf666ff987d18af538b2164c1683629ac689f0a54cb875118be.json
│ │ │ ├── c7eb97fa16701c6d9ee531fa032479a7c771292f3e9d8921d3ea12a4897fd43a.json
│ │ │ ├── c846b5de286163b5eed16ff6d6e00f2038f30bf7edd2ab3f8d831369e190ae18.json
│ │ │ ├── c850d65440fa6ea38db7300c89c5052b3db430ea2c7d20d35cf38c6b41807d0d.json
│ │ │ ├── c8719ca6bbbd7a4815ce5f982745c94d54d2f753312c08b96964ab7ead661291.json
│ │ │ ├── ca0c19553fc1b40bdec0973b3e0c5363d6bbbf69d9961567483ba1c1cb9023eb.json
│ │ │ ├── cb3b2a1c1afd8d0837cc29b5040ec984dc0c0070af2135d0b0e5cb5ff726f2a7.json
│ │ │ ├── cdc5059cf09416d24d03fe57a48af5219727866c84b8109d986456fbaa789dc4.json
│ │ │ ├── cf17cbc72af5f2b56eff6cc03157817d7552c6c5e4565e97278e02ae69a362f3.json
│ │ │ ├── cf6668c54cd4f65d268a17df44f100e1cb77f91eae90dd2dc052564731b2f195.json
│ │ │ ├── d1289d5406d360e3ff617e213db4e262cd43a32711a61aba97bdcd0a906cbcb5.json
│ │ │ ├── d12cd2b31145213be1b4d89e7be64f3aecc122841cdeac7660eceae3dd871f01.json
│ │ │ ├── d12dccf07d58d7c0a2b5ea5b6aa821aa419fdc8b34a48939ebf9c565bc439551.json
│ │ │ ├── d1392fc7270aa978a815d8dbe99ea56de021ee699664635d53b8df5b2988c835.json
│ │ │ ├── d1f2090208e1aab3b0e377278b02e9fc1007e5e1724c9000fb69d050365c8660.json
│ │ │ ├── d3456cd690135f9630e6b0bb79fef5d205804c7acabffc61cbd5ac4032b17bc7.json
│ │ │ ├── d51b2eb414ee4ea432987cd45d6b623013e994a6080c89cde57b69004b576e9d.json
│ │ │ ├── d5676c133bc21ec531e7c17c5e9416781e3459852a4e6e1fac9c77ecdb5c05a2.json
│ │ │ ├── d606b856d99eb59fe3e5378e597bbeff679c812e9f1b173ecced4639ede8ad11.json
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
│ │ │ ├── d899e8cc575f125b908d78f0c8d34408a4af1972ee0544d0517f8d1933239214.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
│ │ │ ├── db81ec42bfd68175829c568dac66c2844afba128b94afa2accdf24ed67aeccaf.json
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
│ │ │ ├── ddd79719e349936349e9ec7684d3eec146d3b73f95a419ad142ae603ab199acd.json
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
│ │ │ ├── e2967fb2aed9420b2cdf823d95f971b7038323c97a6ed139ce63b7a1912381d1.json
│ │ │ ├── e2c62f6012dcae176d19340f6db8db3e94547ebbc4a21577b63caf35cc72ecfb.json
│ │ │ ├── e3ee8d9942c74a74be83ca00d5438e4a1e1cb5b23f55d95d308a42954e4757f0.json
│ │ │ ├── e86dc6bb7703d4329623bd33ccbc5e5e6c6daebf8d36d84fd415d6f868c779b0.json
│ │ │ ├── e8738fdd0a67e92abd0ce636990ee001a2fc7586e9eda224915f33cb80d3b2c6.json
│ │ │ ├── e8e380264b41f789dddd25b7af5d0e3c517b957a14fe8ba2ce7bb8a529e005cf.json
│ │ │ ├── e9589ded446a5befb285a0a2e93a182ca9431c76661e68c9210ed252580dd7f0.json
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
│ │ │ ├── ec3570ad9673cc5dfc9a0d12f208b7f4d897d28d1ae2974d6a3fa63ad27598d3.json
│ │ │ ├── eccad1f007b52643ce8d81576efb7d1969ecbff2e9db288989fd9a9c21fd7288.json
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
│ │ │ ├── ee557c6ed214858651cd3ebceb5e0fd00999354a4c54bb379f26c64b48b509ab.json
│ │ │ ├── efd113025c3fb3c0becaecd8090e8494b13d92ff3c49220d54665b3a8af586af.json
│ │ │ ├── f081ba494afddbf02c2599aedee9fc9d38ccdb159ade7f07ce3bdad3323a001e.json
│ │ │ ├── f1af209a02f2ec5c5a344e3fa7dd8ad7d443e0479e55f5465449c0a2eed314a1.json
│ │ │ ├── f2eacc54ffb4b9802045711c0fb0ac7210c95f21ed6ae78011fe0496951aa086.json
│ │ │ ├── f3a6285dcf9eb39df500bc0b76b03b5941103e8b90c06ffaeab1b3129f18fedc.json
│ │ │ ├── f3fe81220df98d0410b1505fa995df65427035dd552c85f1d80e35bb75a9aba1.json
│ │ │ ├── f48decaf1220666fbda1a5328b963dbd999caab9ebbb1d84459d4c556a6e6ba7.json
│ │ │ ├── f537b36e2ee33d1303c10e950a6910cc7fd954c7fce3220be2c95d75d70dfd40.json
│ │ │ ├── f72f6f3fe9d226e4717e158dedd318fff0a1d52ece54f965311c2b4f862dcfee.json
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
│ │ │ ├── f92b94479afedf47039d8821a77facb55d9a862338e85876b80cac3b41c844f6.json
│ │ │ ├── fa4a8a35e96c919372da27c7af11abea0c89fff941c0b20dbc4e48c15f32597c.json
│ │ │ ├── fb6825c4e9ffba13d17a2da18a1e07aef792089a7cd24b31e7f4f32ee39287e8.json
│ │ │ ├── fbfc351c35c5272de5612b4740e380b06a24c37f8074b977e35f62ad85a6dcc0.json
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
│ │ │ ├── fd714e03c631694d30457ac529d05d8d131c183665e086246da607f752dea5eb.json
│ │ │ ├── fe7f3f3c4b234398f7c0fb728456ac41c92c6e939634e147d2fdda12067df842.json
│ │ │ ├── fe8b7b639a9f98fd1b883b7dccf8490ee905a2a21df5671ca1838a173fa0f921.json
│ │ │ └── ff862e0ffad7bba911716072ae82f268240d82105ffefe78c7714a160c4c31ba.json
│ │ ├── semantic
│ │ │ └── p5e80268fecd6
│ │ │ ├── 0be9cb2f924f20c8cd09e0361a90c8f0b4c8859120101bbe21eee930294ebdfd.json
│ │ │ ├── 0c4f87b180cd5b0576b197ff5cb0e9cc5dfd8d82d97d618036df00c7955fdbac.json
│ │ │ ├── 14328edb08c286b631c2ef0ead8ac33b522157be84855de346e8c0688e30c965.json
│ │ │ ├── 14e03f8f9b4ebd7cf018188e2351ca859aa4e27d02d5dbfc7b1f8b932d282d16.json
│ │ │ ├── 212a2dc6dcc30c1b45f6cf5bfa9bf16386fa138bf382bbde438f8d6883769b29.json
│ │ │ ├── 2679b1fb1647af24f007334e6560733c957ca3cf3831eb02a9c8fb7a7ae44d62.json
│ │ │ ├── 296d532aced4658e74a296977efe4fd8a2ec3cf02a997ce3837f59ce36e61343.json
│ │ │ ├── 2a13d2bb6f18a81d9511481df0edcdb1a8779e1ec03e919991693cae7e84c8e0.json
│ │ │ ├── 2ac09852870126bb1792a5e9242b34d3f55365bfa6e3b09aa432461a6c72045e.json
│ │ │ ├── 2d3b8c0bb7eea6a50a24093abfc3457c0938079b0771b2568498060f6f3a4a74.json
│ │ │ ├── 42a95a4a8f651dbebe297ce92beca376cb19eab99d6ef6b5d10a16d79d1842e4.json
│ │ │ ├── 538cdc2fb71798c581f20d37d52a1484c2ef21691d2e01ee79edcf253ba06394.json
│ │ │ ├── 569db52f8b9229dc3cd5cbde156a4ac012ab711d32bdeb4175b60ba863ac8cd0.json
│ │ │ ├── 57dba5c2a37beb12ccc107807b7544f687d6d8ba6394c8977c3c59b433d2dab4.json
│ │ │ ├── 5ad5f87fc2eb132405e3aab9ef8a84e41f76c958fbcb188873c86abd4fbc12fd.json
│ │ │ ├── 5c0f2ea11f5bdcc5272d6344c9015c20bf79f315d74a910d56bf7a2922c92315.json
│ │ │ ├── 5dbe87f1a5fa172581ed5abebee35b5d08ff488e798a334992a2756bff277cdc.json
│ │ │ ├── 6aaa93a769a41eb1d37a91608cd6f98b9252c2a5598bd859a130986429d1d53f.json
│ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json
│ │ │ ├── 82db37687728a8df60ccf231c06838b4a0074d22ad93e89d3f5e93d4de48132f.json
│ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json
│ │ │ ├── 8b6c538ab1a901fb71decc7f7f511c8c67a76d72f05f02b1bddc10f5026f9e4e.json
│ │ │ ├── 96f5469edd566b92d9d38423a27779142fbe2b77cfc9d85005d43ffc99ce3de1.json
│ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json
│ │ │ ├── a3f3d3cf99dd02b4d8c41297d4634e120953f1650257c0c6a1888481aa4c5745.json
│ │ │ ├── aa41b77ce8563f4b4bee015be5ffa598f1c98d4f2854ec9a465f271e8c3f974b.json
│ │ │ ├── b5f6ef338a857f51d581abe7c50f57f41757aa6900bf94f6ed8fc2ad442f6019.json
│ │ │ ├── be1200d7c9b2881ba54c7fa95daf5f3e2ce993619b974c7760a9d44a416e00c0.json
│ │ │ ├── c109dbf29a8233cec6f0e4e3c1256a94ecee99887756af2060e620aaa5979e4b.json
│ │ │ ├── c1d9e0aa5d7450242681eebd003d4f502744a3624112b10ab091011a50820ce3.json
│ │ │ ├── c89f13bada10536a104ce57788562563a61c2682430e4273ca3195890d45c68c.json
│ │ │ ├── c97d49fed39b9aca8dea35a49c3e031a641420e4452e61d1ea2a912e899f6dd9.json
│ │ │ ├── cacf6773249daafca21d21004106209d86aa215140de071c64363aff4d9e5431.json
│ │ │ ├── cfb57017bc764ee7e9011b52f1e44ad2f41b2670778af73232ecfc309b406a0d.json
│ │ │ ├── dc89fcddf8c6f20bc9f52ae15e5cc8b226320d2776dc8f27be16894e53165013.json
│ │ │ ├── dd78862d0f7862653c702e86f7acfabfe33ec5d85d40ba764138c0d1784f7b06.json
│ │ │ ├── de67f63e3f49d4ee32811dd67676da33a1e584f671d396fca130f3681d54bd76.json
│ │ │ ├── e86dc6bb7703d4329623bd33ccbc5e5e6c6daebf8d36d84fd415d6f868c779b0.json
│ │ │ ├── e9ca16b55daa5d6bdf6b0257a4e08c35caeae32eaaeb5c0be11532b77ab658ea.json
│ │ │ ├── ea11661eb53bce4c7a43acb2d636de01d960acdad246c1f1372df351e25bac25.json
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
│ │ │ ├── f71a50c524a31da468cf7c0c08d929d4d31bf17e77473db59b5830f4c583ce25.json
│ │ │ ├── f7fdf480e35ed096b57529bfcafd8695aff9a98851050be1f00e4a736ad79719.json
│ │ │ └── fb1977d392a5009580c7a608fd403ac2ae96c72f6f2835ba6e49cf662dd0bf6d.json
│ │ ├── semantic-deep
│ │ │ └── pd68e17f4cee0
│ │ │ ├── 0c4f87b180cd5b0576b197ff5cb0e9cc5dfd8d82d97d618036df00c7955fdbac.json
│ │ │ ├── 13fa2da33c99b59f959d42f291dbd55c0d607fcab30d71775c9eac516d8d50d8.json
│ │ │ ├── 14e03f8f9b4ebd7cf018188e2351ca859aa4e27d02d5dbfc7b1f8b932d282d16.json
│ │ │ ├── 24288109cc2c5a8ccae2a63e9174bb373f133d8ce9400865caf81bd04fb5a2ce.json
│ │ │ ├── 2679b1fb1647af24f007334e6560733c957ca3cf3831eb02a9c8fb7a7ae44d62.json
│ │ │ ├── 296d532aced4658e74a296977efe4fd8a2ec3cf02a997ce3837f59ce36e61343.json
│ │ │ ├── 2a13d2bb6f18a81d9511481df0edcdb1a8779e1ec03e919991693cae7e84c8e0.json
│ │ │ ├── 2d3b8c0bb7eea6a50a24093abfc3457c0938079b0771b2568498060f6f3a4a74.json
│ │ │ ├── 32267f72ce9ef01cd2e804d43c6828549af8eb6f868c76e7d1619502a953f14d.json
│ │ │ ├── 40a84c5465882009ab69d2874adbb9aaed373cd706d70c92e9e1f4c35640472e.json
│ │ │ ├── 4280dd1094314da773fe73ba1ffb91f58f22eea26281bd5cb51f5e9a201c95f6.json
│ │ │ ├── 42a95a4a8f651dbebe297ce92beca376cb19eab99d6ef6b5d10a16d79d1842e4.json
│ │ │ ├── 4b8f3b6c0ccb3b5494fb2af8c0f9892b6b198a8d503f53498bd1fe2548ef112a.json
│ │ │ ├── 538cdc2fb71798c581f20d37d52a1484c2ef21691d2e01ee79edcf253ba06394.json
│ │ │ ├── 569db52f8b9229dc3cd5cbde156a4ac012ab711d32bdeb4175b60ba863ac8cd0.json
│ │ │ ├── 57dba5c2a37beb12ccc107807b7544f687d6d8ba6394c8977c3c59b433d2dab4.json
│ │ │ ├── 5ad5f87fc2eb132405e3aab9ef8a84e41f76c958fbcb188873c86abd4fbc12fd.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 6aaa93a769a41eb1d37a91608cd6f98b9252c2a5598bd859a130986429d1d53f.json
│ │ │ ├── 7101777f271bbc6bce9a2a4fd3bcf487bb62a846a2436bc8c70008bfb23ce33b.json
│ │ │ ├── 7db83b9e384d80a1584b152f9fbc781e0ae5a7524dac0a57ce98bb4d3432bfae.json
│ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json
│ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 92e1d82ed4571249c09163413ceae360dd4d7d03c162c554f3b5a47e94474cf1.json
│ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json
│ │ │ ├── a3f1dac3e797de04ab83d263a12eda947904d8e5015b77363db2d29aba8b4928.json
│ │ │ ├── aa41b77ce8563f4b4bee015be5ffa598f1c98d4f2854ec9a465f271e8c3f974b.json
│ │ │ ├── b205a6dda565ed51a38a6b75c509f41589425f0c2006aad838978f5dc6810b91.json
│ │ │ ├── b425db4362cb5940736764d1db891a2a92493f65e908c61b5315c051c8c806f2.json
│ │ │ ├── b5f6ef338a857f51d581abe7c50f57f41757aa6900bf94f6ed8fc2ad442f6019.json
│ │ │ ├── b60e18af0bc21c2c0c8a3be7f6291eb06de84c200a12cfdbecc64cf843a44099.json
│ │ │ ├── be1200d7c9b2881ba54c7fa95daf5f3e2ce993619b974c7760a9d44a416e00c0.json
│ │ │ ├── c16ecf9a717a43b849867c613092f411b6468249ea9d9220133743397ec47bd9.json
│ │ │ ├── c1d9e0aa5d7450242681eebd003d4f502744a3624112b10ab091011a50820ce3.json
│ │ │ ├── c97d49fed39b9aca8dea35a49c3e031a641420e4452e61d1ea2a912e899f6dd9.json
│ │ │ ├── cacf6773249daafca21d21004106209d86aa215140de071c64363aff4d9e5431.json
│ │ │ ├── cfb57017bc764ee7e9011b52f1e44ad2f41b2670778af73232ecfc309b406a0d.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── dc89fcddf8c6f20bc9f52ae15e5cc8b226320d2776dc8f27be16894e53165013.json
│ │ │ ├── dd78862d0f7862653c702e86f7acfabfe33ec5d85d40ba764138c0d1784f7b06.json
│ │ │ ├── de67f63e3f49d4ee32811dd67676da33a1e584f671d396fca130f3681d54bd76.json
│ │ │ ├── e13320e9716b0be66b7cc677a64b44cf3127f7317f35cfa3b849dfbaa9c1f367.json
│ │ │ ├── e1c07127e4cef79fe0bfc63a0ab904e5d1b0d9c4be5278b553b6e48d39753b4d.json
│ │ │ ├── f0478454ef37ecdc5128db97c18d0ffd92005c1d1db586b6750d985e30a25d76.json
│ │ │ ├── f71a50c524a31da468cf7c0c08d929d4d31bf17e77473db59b5830f4c583ce25.json
│ │ │ ├── f7fdf480e35ed096b57529bfcafd8695aff9a98851050be1f00e4a736ad79719.json
│ │ │ ├── fb1977d392a5009580c7a608fd403ac2ae96c72f6f2835ba6e49cf662dd0bf6d.json
│ │ │ └── fb9193d41dc162bc3899036e6f38f0f746acc863e37f2d9f166a5ca79ec008c0.json
│ │ ├── last_query_stamp
│ │ └── stat-index.json
│ ├── .graphify_analysis.json
│ ├── .graphify_labels.json
│ ├── .graphify_labels.json.sig
│ ├── .graphify_root
│ ├── .graphify_semantic_marker
│ ├── backup-graph-preibackupignore-20261009-191817.json
│ ├── backup-graph-presemantic-20261009-193633.json
│ ├── backup-graph-preupdate-20261009-191412.json
│ ├── backup-manifest-20261009-194120.json
│ ├── graph.html
│ ├── graph.json
│ ├── GRAPH_REPORT.md
│ └── manifest.json
├── integrations
│ ├── bale
│ │ ├── __init__.py
│ │ └── providers.py
│ ├── sms
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── behin.py
│ │ ├── console.py
│ │ ├── factory.py
│ │ ├── http.py
│ │ ├── normalize.py
│ │ ├── providers.py
│ │ ├── sms0098.py
│ │ ├── sms0098_codes.py
│ │ └── sms0098ws.py
│ ├── __init__.py
│ ├── announcements.py
│ └── voip.py
├── khatibi
│ ├── __init__.py
│ ├── asgi.py
│ ├── production_settings.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── scripts
│ ├── coordination_hold_order_probe.py
│ ├── entezar_action_audit.py
│ └── hold_write_verification.py
├── static
│ └── .gitkeep
├── tests
│ ├── __init__.py
│ ├── factories.py
│ ├── test_customer_name_fix.py
│ ├── test_customers.py
│ ├── test_entezar_action_simulation.py
│ ├── test_factors.py
│ ├── test_inventory.py
│ ├── test_models_contract.py
│ ├── test_sms.py
│ ├── test_sms_phone_normalize.py
│ ├── test_urls_contract.py
│ ├── test_view_smoke.py
│ └── test_visit_policy.py
├── .clinerules
├── .dockerignore
├── .gitignore
├── .graphifyignore
├── 0098sms.md
├── AGENTS.md
├── backup-20261020-155504.sqlite3
├── backup-20261020-155539.sqlite3
├── backup-20261020-155613.sqlite3
├── backup-20261020-155723.sqlite3
├── backup-20261020-155757.sqlite3
├── backup-20261020-155827.sqlite3
├── check_finance.py
├── CLAUDE.md
├── db.json
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── financial_audit_20260816_151225.csv
├── fix_customer_names.md
├── fix_customer_phones.md
├── GEMINI.md
├── ghost_hunter.py
├── iranianNamesDataset.json
├── khatibi.zip
├── local.env
├── manage.py
├── normalize_customer_sms_phones.md
├── production.env
├── project_tree.py
├── recalc_last_bazdid.md
├── reconciliation_tool.py
├── requirements.txt
├── requirements2.txt
├── ripwire - README.md
├── run.sh
├── screencapture-localhost-8000-panel-sms-templates-2026-10-09-23_01_18.png
└── stop.sh
+100
View File
@@ -75,3 +75,103 @@ class StoreRoomAdmin(admin.ModelAdmin):
"""نمایش تعداد مالکین در لیست اصلی انبارها"""
return obj.owners.count()
get_owners_count.short_description = "تعداد مالکین"
# ============================================================
# مدیریت پیامک (SMS)
# ============================================================
from django import forms
from django.contrib import messages
class SMSProviderConfigForm(forms.ModelForm):
class Meta:
model = SMSProviderConfig
fields = '__all__'
widgets = {
'password': forms.PasswordInput(render_value=True),
'api_token': forms.PasswordInput(render_value=True),
}
@admin.register(SMSProviderConfig)
class SMSProviderConfigAdmin(admin.ModelAdmin):
form = SMSProviderConfigForm
list_display = ('provider', 'is_enabled', 'sender', 'username',
'allow_backlog', 'backlog_max', 'max_per_run',
'send_interval_seconds', 'updated_at')
readonly_fields = ('updated_at',)
fieldsets = (
("اتصال سرویس‌دهنده", {
'fields': ('provider', 'is_enabled', 'api_url', 'username', 'password',
'sender', 'api_token', 'test_message', 'timeout_seconds'),
}),
("وب‌سرویس ۰۰۹۸ (فقط provider وب‌سرویس)", {
'fields': ('webservice_url', 'panel_no'),
}),
("کنترل جریان ارسال", {
'fields': ('scan_since', 'allow_backlog', 'backlog_max',
'max_per_run', 'send_interval_seconds', 'updated_at'),
}),
)
def has_add_permission(self, request):
# سینگلتون: فقط ویرایش رکورد موجود.
return not SMSProviderConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display = ('title', 'sms_type', 'is_enabled', 'auto_send', 'offset_days',
'repeat_interval_days', 'repeat_interval_months')
list_editable = ('is_enabled', 'auto_send')
list_filter = ('sms_type', 'is_enabled', 'auto_send')
search_fields = ('title', 'text')
readonly_fields = ('updated_at',)
@admin.register(CustomerSMSSetting)
class CustomerSMSSettingAdmin(admin.ModelAdmin):
list_display = ('customer', 'sms_type', 'is_enabled')
list_filter = ('sms_type', 'is_enabled')
raw_id_fields = ('customer',)
search_fields = ('customer__name_family',)
@admin.register(SMSQueue)
class SMSQueueAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'sms_type', 'phone_number', 'status',
'delivery_status', 'attempts', 'created_at', 'sent_at')
list_filter = ('status', 'sms_type', 'delivery_status')
raw_id_fields = ('customer', 'created_by')
search_fields = ('phone_number', 'message', 'dedup_key')
readonly_fields = ('created_at', 'updated_at', 'sent_at')
actions = ['approve_selected', 'retry_selected']
@admin.action(description="تأیید موارد انتخاب‌شده (ارسال توسط worker)")
def approve_selected(self, request, queryset):
updated = queryset.filter(status=SMSQueue.Status.PENDING).update(
status=SMSQueue.Status.APPROVED, next_retry_at=None,
)
self.message_user(request, f"{updated} پیامک تأیید شد.", messages.SUCCESS)
@admin.action(description="ارسال مجدد موارد انتخاب‌شده")
def retry_selected(self, request, queryset):
updated = queryset.update(status=SMSQueue.Status.APPROVED, next_retry_at=None, last_error='')
self.message_user(request, f"{updated} پیامک برای ارسال مجدد تأیید شد.", messages.SUCCESS)
@admin.register(SMSLog)
class SMSLogAdmin(admin.ModelAdmin):
list_display = ('id', 'queue', 'attempt_no', 'success', 'provider_code', 'created_at')
list_filter = ('success',)
raw_id_fields = ('queue',)
readonly_fields = ('queue', 'attempt_no', 'success', 'provider_id', 'provider_code', 'error', 'created_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
+33
View File
@@ -1,6 +1,39 @@
import sys
from django.apps import AppConfig
def make_std_streams_unicode_safe():
"""استریم‌های استاندارد را در برابر متن غیر-ASCII مقاوم می‌کند.
کنسول ویندوز با کدپیج cp1252 کار می‌کند و نوشتن متن فارسی روی آن
``UnicodeEncodeError`` می‌دهد (پیام خطا: ``'charmap' codec can't encode ...``).
رخداد واقعی که این تابع برایش نوشته شد: ``print`` متن فارسی داخل provider کنسولِ
پیامک استثنا می‌داد، worker آن را به‌عنوان «ارسال ناموفق» ثبت می‌کرد و پیامک در صف
``failed`` می‌ماند — در حالی که ارسال هیچ مشکلی نداشت. یک استثنای صرفاً نمایشی
نباید هرگز نتیجهٔ یک عملیات کسب‌وکار را عوض کند.
پس اینجا یک‌بار برای همیشه استریم‌ها به UTF-8 با ``errors='replace'`` تنظیم می‌شوند:
هیچ چاپ فارسی‌ای پروسه را نمی‌شکند و فایل‌های لاگ هم فارسی را درست نگه می‌دارند.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
# جریان‌های جایگزین (مثلاً test runner یا jupyter) ممکن است پشتیبانی نکنند.
pass
class AdminPanelConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_panel'
def ready(self):
# پیش از هر کامند/سیگنال: چاپ امن متن فارسی روی کنسول ویندوز.
make_std_streams_unicode_safe()
# ثبت سیگنال‌ها (busy_timeout برای SQLite).
from . import signals # noqa: F401
+4
View File
@@ -3,6 +3,7 @@ import os
import datetime
from .models import *
from .sms.notices import resolve as resolve_sms_notice
def dashBoxData(request):
@@ -53,4 +54,7 @@ def dashBoxData(request):
'bazdid': bazdid
}
# پیام نتیجهٔ عملیات پیامک (جایگزین flash message) از ?notice=<code>.
data.update(resolve_sms_notice(request))
return data
@@ -0,0 +1,81 @@
"""پاک‌سازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید.
چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit:<id>:<شمارهٔ چرخه>``
بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه می‌شد. وقتی آن عدد صفر می‌شد،
عددهای بی‌معنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت
می‌شدند؛ نتیجه‌اش قفل دائمی یادآوری برای همان مشتری بود.
قاعدهٔ جدید کلید را با «موعد تقویمی» می‌سازد: ``visit:<id>:<YYYY-MM>``. کلیدهای قدیمی
هرگز با کلید جدید برخورد نمی‌کنند، بنابراین بی‌ضرر ولی بازدارنده‌اند.
این دستور **فقط** کلیدهای بی‌معنا/قدیمیِ دفتر را حذف می‌کند و هیچ مشتری، اقدام یا
پیامکی را تغییر نمی‌دهد.
python manage.py cleanup_visit_dedup --dry-run # فقط گزارش
python manage.py cleanup_visit_dedup # حذف کلیدهای بی‌معنا (cycle > حد)
python manage.py cleanup_visit_dedup --all-legacy # حذف همهٔ کلیدهای قالب قدیمی
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from ...models import SMSDedup
# سقف منطقی شمارهٔ چرخه؛ هر عدد بزرگ‌تر از این، نتیجهٔ باگ تقسیم قدیمی است.
MAX_SANE_CYCLE = 50
LEGACY_PATTERN = re.compile(r"^visit:(\d+):(\d+)$")
NEW_PATTERN = re.compile(r"^visit:(\d+):(\d{4}-\d{2})$")
class Command(BaseCommand):
help = "حذف کلیدهای آلوده/قدیمی دفتر یکتاسازی یادآوری بازدید (بدون تغییر دادهٔ مشتری)."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true",
help="فقط گزارش؛ چیزی حذف نمی‌شود.")
parser.add_argument("--all-legacy", action="store_true",
help="همهٔ کلیدهای قالب قدیمی (visit:<id>:<عدد>) حذف شوند، "
"نه فقط عددهای بی‌معنا.")
def handle(self, *args, **options):
dry_run = options["dry_run"]
all_legacy = options["all_legacy"]
absurd, sane_legacy, current = [], [], []
for key in SMSDedup.objects.filter(key__startswith="visit:").values_list(
"key", flat=True
).iterator():
if NEW_PATTERN.match(key):
current.append(key)
continue
match = LEGACY_PATTERN.match(key)
if match:
cycle = int(match.group(2))
(absurd if cycle > MAX_SANE_CYCLE else sane_legacy).append(key)
else:
absurd.append(key)
doomed = absurd + (sane_legacy if all_legacy else [])
self.stdout.write(self.style.MIGRATE_LABEL("گزارش کلیدهای یادآوری بازدید:"))
self.stdout.write(f" کلیدهای قالب جدید (موعد تقویمی): {len(current):,}")
self.stdout.write(f" کلیدهای بی‌معنا (چرخه > {MAX_SANE_CYCLE} یا ناشناخته): {len(absurd):,}")
self.stdout.write(f" کلیدهای قدیمیِ سالم (چرخه ≤ {MAX_SANE_CYCLE}): {len(sane_legacy):,}")
if absurd:
self.stdout.write(" نمونهٔ کلیدهای بی‌معنا: " + ", ".join(absurd[:5]))
self.stdout.write(f" قابل حذف در این اجرا: {len(doomed):,}")
if dry_run:
self.stdout.write(self.style.WARNING(
"\n[شبیه‌سازی] هیچ کلیدی حذف نشد."
))
return
if not doomed:
self.stdout.write(self.style.SUCCESS("\nانجام شد: چیزی برای حذف نبود."))
return
with transaction.atomic():
deleted, _ = SMSDedup.objects.filter(key__in=doomed).delete()
self.stdout.write(self.style.SUCCESS(
f"\nانجام شد: {deleted:,} کلید حذف شد (فقط از دفتر یکتاسازی)."
))
@@ -0,0 +1,484 @@
"""افزودن قطعیِ پیشوند «آقای/خانم» به ابتدای نام مشتریان، بر پایهٔ فایل نام‌های ایرانی.
سیاست‌ها (کاملاً محافظه‌کارانه):
* **پیش‌فرض بدون نوشتن است** (dry-run). نوشتن فقط با ``--apply`` ممکن است.
* برای اجرای واقعی، علاوه بر ``--apply``، تأیید صریح ``--confirm-production`` لازم است.
* فقط فیلد ``Customer.name_family`` و آن هم تنها در موارد قطعی تغییر می‌کند.
* هیچ جنسیتی ذخیره نمی‌شود؛ هیچ فیلدی به مدل مشتری اضافه نشده است.
اجرا:
python manage.py fix_customer_names --dry-run
python manage.py fix_customer_names --apply --confirm-production
ادامهٔ job متوقف‌شده (پیش‌فرض: خودکار آخرین job ناتمام با همان hash ادامه می‌یابد):
python manage.py fix_customer_names --apply --confirm-production --resume <JOB_ID>
شروع عمدی job جدید (مثلاً پس از تکمیل job قبلی یا تغییر فایل):
python manage.py fix_customer_names --apply --confirm-production --new-job
جزئیات ایمنی:
* فایل JSON در هر اجرا فقط یک بار خوانده و parse می‌شود و هرگز نوشته نمی‌شود.
* تغییرات نام و checkpoint هر batch در یک تراکنش اتمیک روی همان دیتابیس ثبت می‌شوند.
* دامنهٔ job با بیشترین pk مشتریان در لحظهٔ آغاز قفل می‌شود؛ مشتریان جدیدتر وارد نمی‌شوند.
* قفل بر پایهٔ heartbeat است و پس از crash (پس از ``LOCK_TTL``) قابل بازیابی است.
* خطای «database is locked» با backoff نمایی + jitter و تعداد محدود retry می‌شود.
* فرمان هیچ migration خودکاری اجرا نمی‌کند.
"""
import random
import time
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import DEFAULT_DB_ALIAS, connections, transaction
from django.db.models import Max
from django.db.utils import OperationalError
from django.utils import timezone
from admin_panel.models import Customer, CustomerNameFixJob
from admin_panel.name_fix import (ALGORITHM_VERSION, DatasetError,
decide_prefix, load_name_index)
DEFAULT_JSON_FILENAME = "iranianNamesDataset.json"
# مدت اعتبار قفل/heartbeat. batchها کوچک‌اند، پس این مقدار سخاوتمندانه است.
LOCK_TTL = timedelta(minutes=15)
MAX_DB_RETRIES = 4
BACKOFF_BASE_SECONDS = 0.5
# کلیدهای آماری که در ``stats`` مدل job ذخیره و بین اجراها منتقل می‌شوند.
STATS_KEYS = (
"scanned", "changed", "unchanged", "single_word", "has_prefix",
"empty_invalid", "unknown_name", "ambiguous_gender", "batches",
)
# نگاشت «دلیل تصمیم» به «کلید آماری».
REASON_TO_STAT = {
"male": "changed",
"female": "changed",
"single_word": "single_word",
"has_prefix": "has_prefix",
"empty": "empty_invalid",
"unknown_name": "unknown_name",
"ambiguous_gender": "ambiguous_gender",
}
class Command(BaseCommand):
help = (
"افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ فایل نام‌های ایرانی؛ "
"پیش‌فرض dry-run است و نوشتن نیازمند --apply --confirm-production."
)
def add_arguments(self, parser):
parser.add_argument(
"--json-path", default=str(Path(settings.BASE_DIR) / DEFAULT_JSON_FILENAME),
help=f"مسیر فایل دادهٔ نام‌ها (پیش‌فرض: {DEFAULT_JSON_FILENAME} در ریشهٔ پروژه).",
)
parser.add_argument(
"--batch-size", type=int, default=200,
help="تعداد مشتریان در هر batch (پیش‌فرض ۲۰۰).",
)
parser.add_argument(
"--dry-run", action="store_true",
help="صریحاً حالت شبیه‌سازی (پیش‌فرض همین است؛ همراه --apply مجاز نیست).",
)
parser.add_argument(
"--apply", action="store_true",
help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط گزارش (dry-run) تولید می‌شود.",
)
parser.add_argument(
"--confirm-production", action="store_true",
help="تأیید صریح اپراتور برای نوشتن واقعی؛ همراه --apply الزامی است.",
)
parser.add_argument(
"--resume", type=int, default=None,
help="ادامهٔ یک job مشخص با شناسه (pk) آن.",
)
parser.add_argument(
"--new-job", action="store_true",
help="شروع عمدی یک job جدید (حتی اگر job ناتمام یا تکمیل‌شده‌ای وجود داشته باشد).",
)
parser.add_argument(
"--samples", type=int, default=5,
help="تعداد نمونهٔ «قبل/بعد» برای نمایش در گزارش (پیش‌فرض ۵).",
)
# ------------------------------------------------------------------ main
def handle(self, *args, **options):
alias = DEFAULT_DB_ALIAS
batch_size = max(1, options["batch_size"])
samples_wanted = max(0, options["samples"])
apply_changes = options["apply"]
json_path = self._resolve_json_path(options["json_path"])
self._announce_target(alias)
try:
name_index = load_name_index(json_path)
except DatasetError as exc:
raise CommandError(f"فایل داده رد شد و هیچ تغییری اعمال نشد: {exc}")
self._print_dataset_report(name_index)
if options["dry_run"] and apply_changes:
raise CommandError("--dry-run و --apply با هم ناسازگارند.")
if not apply_changes:
counters, samples = self._run_readonly(
name_index, alias, batch_size, samples_wanted)
self._print_summary(counters, samples, dry_run=True, job=None)
return
if not options["confirm_production"]:
raise CommandError(
"برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است. "
"بدون --apply اجرا حالت dry-run است."
)
self._ensure_job_table(alias)
job, resumed = self._claim_job(
alias, name_index, batch_size, options["resume"], options["new_job"])
counters = self._counters_from(job.stats)
note = "ادامهٔ job" if resumed else "job جدید"
self.stdout.write(self.style.MIGRATE_HEADING(
f"job#{job.pk} {note}؛ از pk>{job.last_processed_pk} تا pk<={job.initial_max_pk} "
f"(batch={batch_size})"
))
try:
counters, samples = self._apply(
name_index, alias, batch_size, job, counters, samples_wanted)
except KeyboardInterrupt:
self._finalize(alias, job, counters, CustomerNameFixJob.Status.PAUSED,
note="توقف با Ctrl+C؛ قابل ادامه با --resume")
self.stdout.write(self.style.WARNING(
f"job#{job.pk} متوقف شد؛ برای ادامه: --apply --confirm-production --resume {job.pk}"
))
return
except Exception as exc: # noqa: BLE001 - هر خطای غیرمنتظره: توقف امن
self._finalize(alias, job, counters, CustomerNameFixJob.Status.FAILED,
note=f"خطا: {type(exc).__name__}")
raise CommandError(
f"اجرای job#{job.pk} با خطا متوقف شد و batch جاری rollback شد: {exc}"
)
self._finalize(alias, job, counters, CustomerNameFixJob.Status.COMPLETED,
note="تکمیل شد")
self._print_summary(counters, samples, dry_run=False, job=job)
# -------------------------------------------------------------- helpers
def _resolve_json_path(self, raw_path):
if not raw_path:
raise CommandError("مسیر فایل داده مشخص نشده است.")
path = Path(raw_path).expanduser()
if not path.is_file():
raise CommandError(f"فایل داده پیدا نشد: {path}")
return path
def _announce_target(self, alias):
"""مقصد دیتابیس را بدون افشای credential نمایش می‌دهد."""
conf = settings.DATABASES.get(alias, {})
engine = conf.get("ENGINE", "?")
name = str(conf.get("NAME", ""))
settings_module = getattr(settings, "SETTINGS_MODULE", "")
self.stdout.write(f"مقصد دیتابیس: alias={alias} engine={engine}")
self.stdout.write(f" NAME: {name}")
if "production_settings" in settings_module:
self.stdout.write(self.style.WARNING(
" توجه: ماژول تنظیمات production فعال است."))
self.stdout.write(" (فقط نام/مسیر دیتابیس چاپ می‌شود؛ credential چاپ نمی‌شود.)")
def _print_dataset_report(self, name_index):
rep = name_index.report
self.stdout.write(self.style.MIGRATE_HEADING("اعتبارسنجی فایل داده"))
self.stdout.write(f" مسیر: {name_index.path}")
self.stdout.write(f" SHA-256: {name_index.sha256}")
self.stdout.write(
f" رکورد کل: {rep['records_total']} | کلید قابل‌استفاده: {rep['usable_keys']} | "
f"کلید غیرقابل‌استفاده: {rep['unusable_keys']}"
)
self.stdout.write(
f" ردشده → malformed: {rep['rejected_malformed']} | "
f"نام خالی: {rep['rejected_empty_name']} | "
f"جنسیت نامعتبر: {rep['rejected_unknown_gender']}"
)
self.stdout.write(
f" نام تکراری: {rep['duplicate_keys']} "
f"(هم‌جنسیت: {rep['duplicate_same_gender']} | متناقض: {rep['duplicate_conflict']})"
)
def _ensure_job_table(self, alias):
table = CustomerNameFixJob._meta.db_table
tables = connections[alias].introspection.table_names()
if table not in tables:
raise CommandError(
f"جدول «{table}» وجود ندارد. ابتدا migration را اعمال کن: "
f"python manage.py migrate"
)
def _empty_counters(self):
return {key: 0 for key in STATS_KEYS}
def _counters_from(self, stats):
counters = self._empty_counters()
if isinstance(stats, dict):
for key in STATS_KEYS:
value = stats.get(key)
if isinstance(value, int):
counters[key] = value
return counters
def _tally(self, counters, decision):
counters["scanned"] += 1
counters[REASON_TO_STAT[decision.reason]] += 1
def _finish_counters(self, counters):
counters["unchanged"] = counters["scanned"] - counters["changed"]
return counters
def _collect_sample(self, samples, raw_name, new_name, wanted):
if wanted and len(samples) < wanted:
samples.append((raw_name, new_name))
# ------------------------------------------------------------ execution
def _run_readonly(self, name_index, alias, batch_size, samples_wanted):
"""اسکن فقط-خواندنی همهٔ مشتریان؛ هیچ نوشتنی (نه نام، نه job) رخ نمی‌دهد."""
counters = self._empty_counters()
samples = []
qs = (Customer.objects.using(alias)
.only("id", "name_family").order_by("pk"))
in_batch = 0
for customer in qs.iterator(chunk_size=batch_size):
decision = decide_prefix(customer.name_family, name_index)
self._tally(counters, decision)
if decision.new_name is not None:
self._collect_sample(samples, customer.name_family,
decision.new_name, samples_wanted)
in_batch += 1
if in_batch >= batch_size:
counters["batches"] += 1
in_batch = 0
if in_batch:
counters["batches"] += 1
return self._finish_counters(counters), samples
def _apply(self, name_index, alias, batch_size, job, totals, samples_wanted):
"""پردازش batchبه‌batch با checkpoint پایدار بر پایهٔ primary key."""
samples = []
max_pk = job.initial_max_pk or 0
last_pk = job.last_processed_pk or 0
while last_pk < max_pk:
rows = list(
Customer.objects.using(alias)
.filter(pk__gt=last_pk, pk__lte=max_pk)
.order_by("pk").only("id", "name_family")[:batch_size]
)
if not rows:
break
batch_counters = self._empty_counters()
changes = []
for customer in rows:
decision = decide_prefix(customer.name_family, name_index)
self._tally(batch_counters, decision)
if decision.new_name is not None:
# فقط فیلد نام تغییر می‌کند؛ سایر فیلدها دست‌نخورده می‌مانند.
changes.append(Customer(pk=customer.pk, name_family=decision.new_name))
self._collect_sample(samples, customer.name_family,
decision.new_name, samples_wanted)
new_last_pk = rows[-1].pk
batch_counters["batches"] = 1
self._commit_batch_with_retry(
alias, changes, new_last_pk, batch_counters, job, totals)
last_pk = new_last_pk
return self._finish_counters(totals), samples
def _commit_batch(self, alias, changes, new_last_pk, batch_counters, job, totals):
"""تغییرات نام + checkpoint + heartbeat را در یک تراکنش اتمیک ثبت می‌کند."""
merged = dict(totals)
for key in STATS_KEYS:
merged[key] = merged[key] + batch_counters[key]
merged = self._finish_counters(merged)
now = timezone.now()
with transaction.atomic(using=alias):
if changes:
Customer.objects.using(alias).bulk_update(changes, ["name_family"])
CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update(
last_processed_pk=new_last_pk,
stats=merged,
heartbeat_at=now,
updated_at=now,
status=CustomerNameFixJob.Status.RUNNING,
)
# فقط پس از commit موفق، آمار در حافظه به‌روز می‌شود (retry آمار را دوباره اضافه نمی‌کند).
totals.update(merged)
def _commit_batch_with_retry(self, alias, changes, new_last_pk,
batch_counters, job, totals):
for attempt in range(MAX_DB_RETRIES):
try:
self._commit_batch(alias, changes, new_last_pk,
batch_counters, job, totals)
return
except OperationalError as exc:
locked = "locked" in str(exc).lower()
if not locked or attempt == MAX_DB_RETRIES - 1:
raise
delay = BACKOFF_BASE_SECONDS * (2 ** attempt)
delay += random.uniform(0, delay / 2) # jitter
self.stderr.write(
f"دیتابیس قفل شد؛ تلاش {attempt + 2}/{MAX_DB_RETRIES} پس از {delay:.2f}s…"
)
time.sleep(delay)
# ------------------------------------------------------------ job / lock
def _claim_job(self, alias, name_index, batch_size, resume_id, force_new):
"""قفل را می‌گیرد و job مناسب (جدید یا قابل‌ادامه) را RETURN می‌کند.
خروجی: ``(job, resumed)``. در صورت اجرای هم‌زمان یا تناقض hash،
``CommandError`` پرتاب می‌شود و هیچ تغییری اعمال نمی‌گردد.
"""
Job = CustomerNameFixJob
now = timezone.now()
digest = name_index.sha256
active = (Job.objects.using(alias)
.filter(status=Job.Status.RUNNING).order_by("-pk").first())
if active is not None:
fresh = (active.heartbeat_at is not None
and (now - active.heartbeat_at) < LOCK_TTL)
if fresh and active.pk != resume_id:
raise CommandError(
f"job#{active.pk} همین حالا در حال اجراست (heartbeat تازه). "
"اجرای هم‌زمان مجاز نیست."
)
if not fresh:
# قفل کهنه (crash/قطع برق): آزادسازی کنترل‌شده
Job.objects.using(alias).filter(pk=active.pk).update(
status=Job.Status.FAILED, finished_at=now, updated_at=now,
note="قفل کهنه؛ آزادسازی خودکار پس از crash")
self.stderr.write(
f"job#{active.pk} ناتمام رها شده بود و اکنون به‌عنوان failed آزاد شد.")
if resume_id is not None:
job = Job.objects.using(alias).filter(pk=resume_id).first()
if job is None:
raise CommandError(f"job#{resume_id} پیدا نشد.")
self._check_hash(job, digest)
if job.status == Job.Status.COMPLETED:
raise CommandError(
f"job#{job.pk} قبلاً تکمیل شده است؛ برای اجرای دوباره --new-job بده.")
resumed = True
elif force_new:
job = self._create_job(alias, digest, batch_size, now)
resumed = False
else:
job = (Job.objects.using(alias)
.filter(source_hash=digest, algorithm_version=ALGORITHM_VERSION)
.exclude(status=Job.Status.COMPLETED).order_by("-pk").first())
if job is not None:
resumed = True
else:
done = Job.objects.using(alias).filter(
source_hash=digest, algorithm_version=ALGORITHM_VERSION,
status=Job.Status.COMPLETED).exists()
if done:
raise CommandError(
"روی همین فایل یک job تکمیل‌شده وجود دارد؛ برای اجرای دوبارهٔ "
"کنترل‌شده --new-job بده."
)
job = self._create_job(alias, digest, batch_size, now)
resumed = False
Job.objects.using(alias).filter(pk=job.pk).update(
status=Job.Status.RUNNING, heartbeat_at=now, updated_at=now)
job.refresh_from_db(using=alias)
return job, resumed
def _check_hash(self, job, digest):
if job.source_hash != digest:
raise CommandError(
f"hash فایل ورودی با job#{job.pk} نمی‌خواند؛ ادامه رد شد و هیچ تغییری "
"اعمال نمی‌شود. برای فایل جدید یک job مستقل اجرا کن."
)
if job.algorithm_version != ALGORITHM_VERSION:
raise CommandError(
f"نسخهٔ الگوریتم job#{job.pk} ({job.algorithm_version}) با نسخهٔ فعلی "
f"({ALGORITHM_VERSION}) نمی‌خواند؛ ادامه رد شد."
)
def _create_job(self, alias, digest, batch_size, now):
Job = CustomerNameFixJob
initial_max_pk = (Customer.objects.using(alias)
.aggregate(m=Max("id"))["m"]) or 0
return Job.objects.using(alias).create(
source_hash=digest,
algorithm_version=ALGORITHM_VERSION,
status=Job.Status.RUNNING,
batch_size=batch_size,
initial_max_pk=initial_max_pk,
last_processed_pk=0,
stats=self._finish_counters(self._empty_counters()),
heartbeat_at=now,
note="",
)
def _finalize(self, alias, job, counters, status, note):
"""وضعیت نهایی/میانی job را با روش اتمیک ثبت می‌کند."""
job.refresh_from_db(using=alias)
now = timezone.now()
persisted = self._finish_counters(dict(counters))
finished = status in (CustomerNameFixJob.Status.COMPLETED,
CustomerNameFixJob.Status.FAILED)
finished_at = now if finished else job.finished_at
with transaction.atomic(using=alias):
CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update(
status=status, stats=persisted, updated_at=now, heartbeat_at=now,
finished_at=finished_at, note=note[:255])
job.status = status
job.stats = persisted
job.finished_at = finished_at
# -------------------------------------------------------------- summary
def _print_summary(self, counters, samples, dry_run, job):
if dry_run:
head = "گزارش dry-run — هیچ تغییری نوشته نشد"
else:
head = f"گزارش اجرای واقعی job#{job.pk} ({job.status})"
self.stdout.write(self.style.MIGRATE_HEADING(head))
self.stdout.write(f" مشتریان بررسی‌شده: {counters['scanned']}")
self.stdout.write(f" قابل اصلاح / اصلاح‌شده: {counters['changed']}")
self.stdout.write(f" بدون تغییر: {counters['unchanged']}")
self.stdout.write(
f" تک‌کلمه‌ای: {counters['single_word']} | "
f"دارای پیشوند: {counters['has_prefix']} | "
f"خالی/نامعتبر: {counters['empty_invalid']}"
)
self.stdout.write(
f" ناشناخته در JSON: {counters['unknown_name']} | "
f"جنسیت مبهم/نامعتبر: {counters['ambiguous_gender']}"
)
self.stdout.write(f" batchهای تکمیل‌شده: {counters['batches']}")
if job is not None:
self.stdout.write(
f" شناسهٔ job: {job.pk} | شروع: {job.started_at} | پایان: {job.finished_at}"
)
if samples:
self.stdout.write(" نمونهٔ تغییرات (قبل → بعد):")
for before, after in samples:
self.stdout.write(f" «{before}» → «{after}»")
if dry_run:
self.stdout.write(self.style.WARNING(
"برای اجرای واقعی: --apply --confirm-production"))
@@ -0,0 +1,30 @@
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن مشتریان (phone1/phone2/phone3).
روی دیتابیس production (سرور اصلی) هم قابل اجرا است؛ idempotent است و اجرای
دوباره‌اش تغییری ایجاد نمی‌کند.
python manage.py fix_customer_phones --dry-run # فقط گزارش، بدون تغییر
python manage.py fix_customer_phones # اجرای واقعی
"""
from django.core.management.base import BaseCommand
from admin_panel.sms.phone_cleanup import clean_all
class Command(BaseCommand):
help = "استخراج و استانداردسازی شماره‌های قدیمی مشتریان؛ متن‌های حذف‌شده در فیلد توضیحات می‌مانند."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ چیزی ذخیره نکن.",
)
def handle(self, *args, **options):
dry = options["dry_run"]
stats = clean_all(dry_run=dry)
prefix = "شبیه‌سازی (بدون تغییر): " if dry else "پاک‌سازی انجام شد: "
self.stdout.write(
f"{prefix}{stats['customers']} مشتری، {stats['fields']} فیلد اصلاح شد، "
f"{stats['noted']} یادداشت ثبت شد."
)
@@ -0,0 +1,367 @@
"""استخراج و تجمیع شماره‌های موبایل هر مشتری در ``Customer.sms_primary_phones``.
منابع بررسی (به همین ترتیب اولویت برای حذف تکراری‌ها):
``phone1`` → ``phone2`` → ``phone3`` → رکوردهای ``PhoneNumber`` بر اساس ``id``.
**امنیت داده:** فیلدهای مبدأ، سایر مشخصات مشتری و رکوردهای ``PhoneNumber`` هرگز
ویرایش/حذف نمی‌شوند. تنها ستونی که نوشته می‌شود ``sms_primary_phones`` است.
**قالب ذخیره‌شده:** ``+989xxxxxxxxx`` — همان قالب متعارف پروژه که
``admin_panel.sms.phones.stored_primary_normalized`` انتظار دارد و تمام تست‌های
موجود (``tests/test_sms.py``، ``tests/test_customers.py``) روی آن قفل شده‌اند.
اعتبارسنجی ورودی با استاندارد ۱۱ رقمی ``09xxxxxxxxx`` انجام می‌شود، ولی مقدارِ
ذخیره‌شده همان ``+98…`` است؛ در غیر این‌صورت تطبیق «شمارهٔ اصلی» در پنل و
``resolved_recipients`` هیچ‌وقت نمی‌گیرد و داده بی‌اثر می‌شود.
**تضمین‌ها:**
* پیش‌فرض dry-run است؛ نوشتن فقط با ``--apply``.
* هر batch در ``transaction.atomic`` اختصاصی؛ خطا ⇒ فقط همان batch برمی‌گردد.
* Keyset pagination (``id__gt``) + checkpoint فایل پس از commit موفق هر batch.
* رکوردِ بدون تغییر نوشته نمی‌شود؛ مقدار موجودِ غیرخالی هرگز با «خالی» پاک نمی‌شود.
* سرریز طول (> ``max_length``) ⇒ رکورد دست‌نخورده + ثبت در گزارش.
اجرا:
python manage.py normalize_customer_sms_phones --dry-run
python manage.py normalize_customer_sms_phones --apply
python manage.py normalize_customer_sms_phones --apply --resume
python manage.py normalize_customer_sms_phones --apply --start-id 5000
python manage.py normalize_customer_sms_phones --apply --reset-checkpoint
"""
import json
import os
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from persiantools import digits
from admin_panel.models import Customer
from admin_panel.sms.phones import normalize_primary_values
from integrations.sms.normalize import normalize_mobile
DEFAULT_BATCH_SIZE = 100
DEFAULT_CHECKPOINT_NAME = ".sms_normalization_checkpoint.json"
# مرز طول از خود مدل خوانده می‌شود تا با تغییر مدل هم‌راستا بماند.
FIELD_MAX_LENGTH = Customer._meta.get_field("sms_primary_phones").max_length
LEGACY_FIELDS = ("phone1", "phone2", "phone3")
# حداکثر تعداد شناسهٔ سرریز که در گزارش چاپ می‌شود (بقیه فقط شمرده می‌شوند).
OVERFLOW_SAMPLE = 10
# ارقام «پیش‌شمارهٔ ثابت»: 021، 031، 051، 071، … (برای گزارش، نه تصمیم داده).
LANDLINE_SECOND_DIGITS = set("12345678")
def digit_string(raw):
"""فقط ارقامِ یک مقدار ورودی، با تبدیل ارقام فارسی/عربی به انگلیسی."""
if raw is None:
return ""
text = digits.fa_to_en(digits.ar_to_fa(str(raw).strip()))
return "".join(ch for ch in text if ch.isdigit())
def is_landline(raw):
"""آیا مقدار ورودی شکل شمارهٔ ثابت دارد؟ (فقط برای تفکیک در گزارش)
شامل شمارهٔ ثابت محلی ۸رقمی (مثل ``57387608``) و شمارهٔ ثابت با پیش‌شماره
(مثل ``02112345678`` / ``03133445566``) می‌شود — همان قاعدهٔ مستندشدهٔ پروژه
در ``fix_customer_phones.md``.
"""
text = digit_string(raw)
if len(text) == 8:
return True
if len(text) not in (10, 11) or not text.startswith("0"):
return False
return text[1] in LANDLINE_SECOND_DIGITS
class Command(BaseCommand):
help = (
"استخراج و تجمیع شماره‌های موبایل معتبر هر مشتری در sms_primary_phones "
"(پیش‌فرض dry-run؛ اتمیکِ batch به batch با checkpoint قابل ادامه)."
)
def add_arguments(self, parser):
parser.add_argument(
"--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
help=f"تعداد مشتریان در هر batch (پیش‌فرض {DEFAULT_BATCH_SIZE}).",
)
parser.add_argument(
"--dry-run", action="store_true",
help="صریحاً حالت شبیه‌سازی (پیش‌فرض همین است؛ همراه --apply مجاز نیست).",
)
parser.add_argument(
"--apply", action="store_true",
help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط شبیه‌سازی می‌شود.",
)
parser.add_argument(
"--resume", action="store_true",
help="ادامه از آخرین شناسهٔ ثبت‌شده در فایل وضعیت.",
)
parser.add_argument(
"--start-id", type=int, default=None,
help="شروع دستی از یک شناسه (مشتریان با id بزرگ‌تر پردازش می‌شوند). "
"بر --resume اولویت دارد.",
)
parser.add_argument(
"--reset-checkpoint", action="store_true",
help="پاک‌کردن فایل وضعیت و شروع از ابتدا (در dry-run نادیده گرفته می‌شود).",
)
parser.add_argument(
"--checkpoint", default=None,
help=f"مسیر فایل وضعیت (پیش‌فرض: {DEFAULT_CHECKPOINT_NAME} در ریشهٔ پروژه).",
)
# ------------------------------------------------------------------ main
def handle(self, *args, **options):
batch_size = max(1, options["batch_size"])
apply_changes = options["apply"]
checkpoint_path = Path(
options["checkpoint"] or (Path(settings.BASE_DIR) / DEFAULT_CHECKPOINT_NAME)
)
self.stdout.write(
f"حالت: {'اجرای واقعی (--apply)' if apply_changes else 'شبیه‌سازی (dry-run)'} | "
f"batch={batch_size} | فایل وضعیت: {checkpoint_path}"
)
if options["dry_run"] and apply_changes:
raise CommandError("--dry-run و --apply با هم ناسازگارند.")
self._handle_reset(checkpoint_path, apply_changes, options["reset_checkpoint"])
last_id, source = self._start_point(checkpoint_path, options)
self.stdout.write(f"شروع از مشتریان با id > {last_id} ({source})")
counters = {
"scanned": 0, "updated": 0, "unchanged": 0, "kept_nonempty": 0,
"filtered_landline": 0, "filtered_invalid": 0, "duplicates": 0,
"overflow": 0, "batches": 0,
}
overflow_ids = []
try:
last_id = self._run(
last_id, batch_size, apply_changes, checkpoint_path,
counters, overflow_ids)
except KeyboardInterrupt:
self.stdout.write(self.style.WARNING(
f"متوقف شد (Ctrl+C). آخرین شناسهٔ ثبت‌شده: {last_id}. "
"برای ادامه: --apply --resume"
))
return
except Exception as exc: # noqa: BLE001 - توقف امن با گزارش روشن
raise CommandError(
f"اجرا متوقف شد؛ batch جاری rollback شد و آخرین شناسهٔ معتبر "
f"{last_id} است: {exc}"
)
self._print_report(counters, overflow_ids, apply_changes, last_id)
# ------------------------------------------------------------ start/lock
def _handle_reset(self, checkpoint_path, apply_changes, reset_requested):
if not reset_requested:
return
if not checkpoint_path.exists():
self.stdout.write("فایل وضعیت وجود نداشت؛ چیزی برای پاک‌کردن نبود.")
return
if not apply_changes:
self.stdout.write("--reset-checkpoint در شبیه‌سازی نادیده گرفته شد (فایل وضعیت دست‌نخورده).")
return
try:
checkpoint_path.unlink()
except OSError as exc:
raise CommandError(f"فایل وضعیت پاک نشد ({checkpoint_path}): {exc}")
self.stdout.write(f"فایل وضعیت پاک شد: {checkpoint_path}")
def _start_point(self, checkpoint_path, options):
"""(last_id, توضیح منبع) — اولویت: --start-id سپس --resume سپس ابتدا."""
if options["start_id"] is not None:
return max(0, options["start_id"]), "--start-id"
if options["resume"]:
if not checkpoint_path.exists():
raise CommandError(
f"--resume داده شد ولی فایل وضعیت وجود ندارد: {checkpoint_path}"
)
data = self._read_checkpoint(checkpoint_path)
value = data.get("last_id")
if not isinstance(value, int) or value < 0:
raise CommandError(
f"فایل وضعیت معتبر نیست (last_id={value!r}): {checkpoint_path}"
)
return value, "--resume"
return 0, "ابتدا"
def _read_checkpoint(self, path):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise CommandError(f"فایل وضعیت خوانده نشد ({path}): {exc}")
if not isinstance(data, dict):
raise CommandError(f"فایل وضعیت ساختار معتبر ندارد ({path}).")
return data
def _write_checkpoint(self, path, last_id, batch_size, counters):
"""نوشتن اتمیک فایل وضعیت (tmp + os.replace) با آخرین شناسهٔ موفق."""
payload = {
"last_id": last_id,
"batch_size": batch_size,
"applied": True,
"scanned": counters["scanned"],
"updated": counters["updated"],
"overflow": counters["overflow"],
"batches": counters["batches"],
}
tmp = path.with_name(path.name + ".tmp")
try:
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
except OSError as exc:
raise CommandError(f"فایل وضعیت نوشته نشد ({path}): {exc}")
# ---------------------------------------------------------------- batch
def _run(self, last_id, batch_size, apply_changes, checkpoint_path,
counters, overflow_ids):
"""حلقهٔ اصلی: هر batch یک تراکنش اتمیک + checkpoint پس از commit موفق."""
max_id = (Customer.objects.order_by("-id")
.values_list("id", flat=True).first()) or 0
if last_id >= max_id:
self.stdout.write("مشتری‌ای در دامنهٔ پردازش باقی نمانده است.")
return last_id
while last_id < max_id:
rows = list(
Customer.objects
.filter(id__gt=last_id)
.order_by("id")
.prefetch_related("phones") # جلوگیری از N+1
.only("id", "phone1", "phone2", "phone3", "sms_primary_phones")
[:batch_size]
)
if not rows:
break
pending = []
for customer in rows:
counters["scanned"] += 1
value = self._target_value(customer, counters)
current = (customer.sms_primary_phones or "").strip()
if value is None:
overflow_ids.append(customer.id) # سرریز: دست‌نخورده
continue
if not value:
# مقدار محاسبه‌شده خالی است: مقدار موجودِ مدیر پاک نمی‌شود.
if current:
counters["kept_nonempty"] += 1
continue
if value == current:
counters["unchanged"] += 1
continue
pending.append((customer, value))
if apply_changes and pending:
# یک تراکنش کوتاه برای همین batch: خطا ⇒ فقط همین batch برمی‌گردد.
with transaction.atomic():
Customer.objects.bulk_update(
[self._stage(customer, value) for customer, value in pending],
["sms_primary_phones"])
counters["updated"] += len(pending)
new_last_id = rows[-1].id
counters["batches"] += 1
if apply_changes:
self._write_checkpoint(
checkpoint_path, new_last_id, batch_size, counters)
last_id = new_last_id
self.stdout.write(
f" batch {counters['batches']}: last_id={last_id} | "
f"بررسی‌شده={counters['scanned']} | "
f"{'به‌روزشده' if apply_changes else 'قابل‌به‌روزرسانی'}={counters['updated']} | "
f"سرریز={counters['overflow']}"
)
return last_id
# -------------------------------------------------------------- helpers
def _stage(self, customer, value):
"""مقدار نهایی را روی نمونه می‌گذارد و همان را برمی‌گرداند (برای bulk_update)."""
customer.sms_primary_phones = value
return customer
def _target_value(self, customer, counters):
"""مقدار نهایی ``sms_primary_phones`` برای یک مشتری.
خروجی: رشتهٔ نهایی، ``""`` (شمارهٔ معتبری نیست) یا ``None`` (سرریز > max_length).
"""
raw_values = [getattr(customer, field, "") for field in LEGACY_FIELDS]
numbers = sorted(customer.phones.all(), key=lambda row: row.id)
raw_values.extend(row.phone_number for row in numbers)
self._count_filters(raw_values, counters)
value = normalize_primary_values(raw_values)
if not value:
return ""
if len(value) > FIELD_MAX_LENGTH:
counters["overflow"] += 1
return None
return value
def _count_filters(self, raw_values, counters):
"""شمارش شماره‌های فیلترشده (ثابت/نامعتبر) و تکراری‌های حذف‌شده."""
seen = set()
duplicates = 0
for raw in raw_values:
if raw is None or not str(raw).strip():
continue
number = normalize_mobile(raw)
if not number:
if is_landline(raw):
counters["filtered_landline"] += 1
else:
counters["filtered_invalid"] += 1
continue
if number in seen:
duplicates += 1
else:
seen.add(number)
counters["duplicates"] += duplicates
# -------------------------------------------------------------- report
def _print_report(self, counters, overflow_ids, apply_changes, last_id):
head = ("گزارش شبیه‌سازی (dry-run) — هیچ تغییری ذخیره نشد"
if not apply_changes else "گزارش اجرای واقعی")
self.stdout.write(self.style.MIGRATE_HEADING(head))
self.stdout.write(f" مشتریان بررسی‌شده: {counters['scanned']}")
self.stdout.write(
f" مشتریان {'به‌روزشده' if apply_changes else 'قابل‌به‌روزرسانی'}: "
f"{counters['updated']}"
)
self.stdout.write(f" بدون تغییر: {counters['unchanged']}")
self.stdout.write(
f" شماره‌های فیلترشده: ثابت={counters['filtered_landline']} | "
f"نامعتبر={counters['filtered_invalid']} | "
f"جمع={counters['filtered_landline'] + counters['filtered_invalid']}"
)
self.stdout.write(f" شماره‌های تکراری حذف‌شده: {counters['duplicates']}")
self.stdout.write(
f" سرریز طول (>{FIELD_MAX_LENGTH} کاراکتر، دست‌نخورده): {counters['overflow']}"
)
if overflow_ids:
sample = ", ".join(str(pk) for pk in overflow_ids[:OVERFLOW_SAMPLE])
more = len(overflow_ids) - OVERFLOW_SAMPLE
tail = f" و {more} مورد دیگر" if more > 0 else ""
self.stdout.write(f" شناسه‌های سرریز: {sample}{tail}")
if counters["kept_nonempty"]:
self.stdout.write(
f" مقدار موجودِ غیرخالی که محافظت شد (شمارهٔ معتبری پیدا نشد): "
f"{counters['kept_nonempty']}"
)
self.stdout.write(f" batchهای تکمیل‌شده: {counters['batches']}")
self.stdout.write(f" آخرین شناسهٔ پردازش‌شده: {last_id}")
if not apply_changes:
self.stdout.write(self.style.WARNING(
"برای اجرای واقعی: --apply (و در صورت توقف قبلی: --apply --resume)"
))
@@ -0,0 +1,270 @@
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگ‌ترین تاریخ مؤثر
در میان اقدام‌های ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض می‌کند و هیچ رکورد اقدامی
(از جمله رکوردهای «هماهنگی») را نمی‌خواند/نمی‌نویسد.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
* ``--dry-run``: هیچ نوشتنی انجام نمی‌شود.
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح می‌شود.
بدون ``--confirm=YES`` اجرا نمی‌شود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
می‌گیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمی‌شود.
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` می‌کند.
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمی‌کند.
"""
import os
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from ...models import Action, Customer
from ...visit_policy import as_aware, last_visit_map
CONFIRM_TOKEN = "YES"
SAMPLE_LIMIT = 20
def _bucket(current, new, clear_orphans):
"""دستهٔ تغییر پیشنهادی برای یک مشتری."""
if new is None:
if current is not None and clear_orphans:
return "orphan_cleared"
return "no_change"
if current is None:
return "fill_null"
if new > current:
return "forward"
if new < current:
return "backward"
return "no_change"
class Command(BaseCommand):
help = (
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
"(فقط bazdid + finish؛ هرگز هماهنگی)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش؛ هیچ رکوردی ذخیره نمی‌شود.",
)
parser.add_argument(
"--exact", action="store_true",
help="هر اختلافی را اصلاح کن (شامل عقب‌بردن مقدار). نیازمند --confirm=YES.",
)
parser.add_argument(
"--confirm", default="",
help="برای اجرای --exact باید مقدار YES باشد.",
)
parser.add_argument(
"--clear-orphans", action="store_true",
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان (پیش‌فرض ۵۰۰).",
)
# ------------------------------------------------------------------
def handle(self, *args, **options):
dry_run = options["dry_run"]
exact = options["exact"]
clear_orphans = options["clear_orphans"]
batch_size = max(1, options["batch_size"])
if exact and not dry_run:
if options["confirm"].strip().upper() != CONFIRM_TOKEN:
raise CommandError(
"اجرای --exact بدون تاییدیه مجاز نیست. ابتدا --dry-run --exact "
"را ببینید، از دیتابیس بکاپ بگیرید و سپس با --confirm=YES اجرا کنید."
)
prefix = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(self.style.MIGRATE_HEADING(
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
stats, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
backup_path = None
if exact and not dry_run and pending:
backup_path = self._backup()
self.stdout.write(self.style.SUCCESS(f"بکاپ ساخته شد: {backup_path}"))
if not dry_run and pending:
self._apply(pending)
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
len(pending), backup_path)
# ------------------------------------------------------------------
def _plan(self, exact, clear_orphans, batch_size):
"""محاسبهٔ تغییرات پیشنهادی بدون هیچ نوشتنی."""
visits = last_visit_map()
stats = {
"scanned": 0, "with_visit": 0, "no_change": 0,
"fill_null": 0, "forward": 0, "backward": 0, "orphan_cleared": 0,
"kept_orphan": 0,
}
pending = []
samples = []
qs = Customer.objects.only("id", "name_family", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
new = visits.get(customer.id)
current = as_aware(customer.last_bazdid_dt)
if new is not None:
stats["with_visit"] += 1
bucket = _bucket(current, new, clear_orphans)
if bucket == "no_change":
stats["no_change"] += 1
if new is None and current is not None:
stats["kept_orphan"] += 1
continue
if not exact and bucket in ("backward", "orphan_cleared"):
stats["no_change"] += 1 # حالت پیش‌فرض فقط رو به جلو حرکت می‌کند
continue
stats[bucket] += 1
pending.append((customer.id, new))
if len(samples) < SAMPLE_LIMIT:
samples.append((customer.id, customer.name_family, current, new, bucket))
return stats, samples, pending, self._hamahangi_suspect_count()
def _apply(self, pending):
"""اعمال تغییرات؛ فقط ستون ``last_bazdid_dt`` مشتری نوشته می‌شود."""
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(last_bazdid_dt=target)
# ------------------------------------------------------------------
def _backup(self):
"""یک نسخهٔ سالم از دیتابیس با timestamp می‌سازد؛ خطا یعنی توقف کامل اجرا.
اول ``VACUUM INTO`` (سریع و اتمی) و اگر SQLite اجازه نداد (مثلاً داخل یک
تراکنش باز)، نسخهٔ سازگار با روش رسمی ``sqlite3.Connection.backup``
گرفته می‌شود تا در هر دو حالت یک فایل سالم حاصل شود.
"""
db_path = str(connection.settings_dict.get("NAME") or "")
if not db_path:
raise CommandError("مسیر دیتابیس مشخص نیست؛ بکاپ ممکن نشد.")
target_dir = os.path.dirname(os.path.abspath(db_path))
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(target_dir, f"backup-{stamp}.sqlite3")
try:
with connection.cursor() as cursor:
cursor.execute("VACUUM INTO %s", [backup_path])
except Exception as vacuum_error: # noqa: BLE001
try:
self._copy_database(db_path, backup_path)
except Exception as copy_error: # noqa: BLE001
raise CommandError(
"بکاپ گرفتن شکست خورد؛ هیچ تغییری اعمال نشد: "
f"{vacuum_error} / {copy_error}"
)
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
raise CommandError("فایل بکاپ ساخته نشد یا خالی است؛ اجرا متوقف شد.")
return backup_path
def _copy_database(self, source_path, target_path):
"""نسخهٔ پشتیبان سازگار از فایل SQLite (روش رسمی ``backup``)."""
import sqlite3
source = sqlite3.connect(self._source_uri(source_path), uri=True)
try:
target = sqlite3.connect(target_path)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
@staticmethod
def _source_uri(source_path):
"""URI فقط‌خواندنی؛ اگر مسیر خودش URI باشد (دیتابیس تست) دست‌نخورده می‌ماند."""
if source_path.startswith("file:"):
return source_path
return f"file:{source_path}?mode=ro"
def _hamahangi_suspect_count(self):
"""تعداد مشتریانی که مقدارشان دقیقاً برابر تاریخ یک رکورد هماهنگی است.
تشخیص **تقریبی** است: ``last_bazdid_dt`` هیچ ارجاعی به رکورد منبع ندارد،
پس برابری مقدار فقط «نشانه» است، نه اثبات.
"""
stamps = {}
rows = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
).values_list("customer_id", "actionDateTime", "finishDateTime")
for customer_id, action_dt, finish_dt in rows.iterator():
stamps.setdefault(customer_id, set()).update(
v for v in (as_aware(action_dt), as_aware(finish_dt)) if v is not None
)
if not stamps:
return 0
count = 0
qs = Customer.objects.filter(id__in=list(stamps)).only("id", "last_bazdid_dt")
for customer in qs.iterator():
if as_aware(customer.last_bazdid_dt) in stamps[customer.id]:
count += 1
return count
def _report(self, stats, samples, suspects, dry_run, exact, clear_orphans,
changed, backup_path=None):
style = self.style
label = "[شبیه‌سازی] " if dry_run else ""
mode = "exact" if exact else "default(forward-only)"
self.stdout.write(style.MIGRATE_LABEL(
f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید (حالت: {mode}):"
))
self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}")
self.stdout.write(f" دارای بازدید معتبر: {stats['with_visit']:,}")
self.stdout.write(f" بدون تغییر: {stats['no_change']:,}")
self.stdout.write(f" پر کردن مقدار خالی: {stats['fill_null']:,}")
self.stdout.write(f" اصلاح رو به جلو: {stats['forward']:,}")
self.stdout.write(f" اصلاح رو به عقب: {stats['backward']:,}")
if clear_orphans:
self.stdout.write(f" پاک‌سازی یتیم: {stats['orphan_cleared']:,}")
else:
self.stdout.write(
f" بدون بازدید ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(style.WARNING(
f" نشانهٔ آلودگی هماهنگی (تقریبی): {suspects:,}"
))
if samples:
self.stdout.write("\n نمونهٔ تغییرات (id | مشتری | فعلی → جدید | دسته):")
for customer_id, name, current, new, bucket in samples:
self.stdout.write(f" {customer_id} | {name} | {current} → {new} | {bucket}")
if dry_run:
self.stdout.write(style.WARNING(
f"\n[شبیه‌سازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
))
elif changed:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {changed:,} مشتری به‌روزرسانی شد (فقط ستون last_bazdid_dt)."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل هم‌راستا است)."
))
if backup_path:
self.stdout.write(f" بکاپ: {backup_path}")
@@ -97,7 +97,8 @@ class Command(BaseCommand):
# آمار نظرسنجی‌ها
nazar_stats = Action.objects.filter(
nazarDT__range=(today_start, today_end),
grade__gt=0
grade__gt=0,
deleted_at__isnull=True,
).aggregate(
avg_grade=Avg('grade'),
total_count=Count('id')
@@ -0,0 +1,118 @@
"""استعلام وضعیت تحقق پیامک‌های ارسال‌شده (رسیده/نرسیده به گوشی).
فقط برای provider وب‌سرویس 0098SMS معنا دارد؛ تنها آن provider با
``SendSMSWithID`` شناسهٔ پیامک (smsid) برمی‌گرداند و ``smsdeliveryState``
وضعیت تحویل را می‌دهد. برای سایر providerها دستور no-op است.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "استعلام وضعیت تحویل پیامک‌های ارسال‌شده از وب‌سرویس 0098SMS."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=30.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument("--batch", type=int, default=100, help="تعداد ردیف در هر دور")
parser.add_argument(
"--min-age", type=int, default=60,
help="حداقل فاصله (ثانیه) از زمان ارسال پیش از نخستین استعلام",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
self._emit("استعلام دلیوری پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
try:
processed = self._drain_batch(
options["batch"], options["min_age"],
)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از استعلام دلیوری؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("استعلام دلیوری پیامک متوقف شد.", style=self.style.SUCCESS)
# ------------------------------------------------------------------ helpers
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""چاپ با تحمل کدگذاری محدود کنسول (مثل worker پیامک)."""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _sleep_interruptible(self, seconds):
deadline = time.monotonic() + max(0, seconds)
while not self._stop and time.monotonic() < deadline:
time.sleep(min(0.5, deadline - time.monotonic()))
def _drain_batch(self, batch, min_age):
config = SMSProviderConfig.load()
if config.provider != "sms0098ws":
# smsid تنها متعلق به این provider است؛ استعلام با بقیه بی‌معناست.
logger.info("provider فعلی رهگیری دلیوری ندارد؛ استعلام رد شد.")
return 0
now = timezone.now()
cutoff = now - timedelta(seconds=max(0, min_age))
# پیامک‌های ارسال‌شده که شناسه دارند و وضعیت‌شان هنوز نهایی نشده است.
query = SMSQueue.objects.filter(
Q(status=SMSQueue.Status.SENT, provider_id__gt="", sent_at__lte=cutoff),
Q(delivery_checked_at__isnull=True)
| Q(delivery_status__in=["", SMSQueue.DeliveryStatus.PENDING,
SMSQueue.DeliveryStatus.UNKNOWN]),
)
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for row in query.order_by("sent_at")[:batch]:
if self._stop:
break
result = provider.get_delivery_status(row.provider_id)
if result is None:
# خطای ارتباط/تنظیمات: ردیف را رد کن تا دور بعد دوباره تلاش شود.
continue
row.delivery_status = result.status
row.delivery_checked_at = timezone.now()
row.save(update_fields=["delivery_status", "delivery_checked_at", "updated_at"])
processed += 1
self._emit(
f" دلیوری #{row.pk} ({row.provider_id}): {result.status} "
f"{'(نهایی)' if result.is_final else '(در انتظار)'}",
)
return processed
@@ -0,0 +1,50 @@
"""زمان‌بند/اسکنر: پیدا کردن موارد سررسیدشده و قرار دادن آن‌ها در صف پیامک."""
import logging
import signal
import time
from django.core.management.base import BaseCommand
from admin_panel.sms.scanner import run_all
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "اسکن یادآوری‌های پیامک و قرار دادن آن‌ها در صف."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true", help="اجرای پیوسته")
parser.add_argument("--interval", type=float, default=300.0, help="فاصلهٔ بین اجراها (ثانیه)")
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
loop = options["loop"]
interval = options["interval"]
while not self._stop:
results = run_all()
summary = ", ".join(f"{name}={count}" for name, count in results.items())
self._emit(f"اسکن پیامک: {summary}")
if not loop:
break
# خواب قابل‌قطع
slept = 0.0
while slept < interval and not self._stop:
time.sleep(min(1.0, interval - slept))
slept += 1.0
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز)."""
try:
self.stdout.write(text)
except UnicodeEncodeError:
self.stdout.write(text.encode("ascii", "replace").decode("ascii"))
@@ -0,0 +1,179 @@
"""worker تخلیه‌کنندهٔ صف پیامک.
در یک پردازش جدا اجرا می‌شود تا کندی/خطای سامانه پیامک به برنامه اصلی ضربه نزند.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSLog, SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "تخلیهٔ صف پیامک و ارسال از طریق سرویس‌دهندهٔ تنظیم‌شده."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=3.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument(
"--batch", type=int, default=None,
help="تعداد پیامک در هر دور (پیش‌فرض: max_per_run از تنظیمات پنل)",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
batch = options["batch"]
self._emit("worker پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
# یک دور خراب (خطای دیتابیس، provider، …) نباید کل worker را از پا دربیاورد؛
# در غیر این‌صورت صف تا ری‌استارت دستی راکد می‌ماند.
try:
processed = self._drain_batch(batch)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از worker پیامک؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("worker پیامک متوقف شد.", style=self.style.SUCCESS)
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز).
چاپ، عملیات جانبی است: اگر کنسول نتواند متن را بنویسد نباید ارسال‌ها را
متوقف یا «ناموفق» کند. اول متن کامل، بعد نسخهٔ ASCII-شده، و در نهایت سکوت.
"""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _drain_batch(self, batch):
config = SMSProviderConfig.load()
if not config.is_enabled:
logger.info("ارسال پیامک از تنظیمات غیرفعال است؛ صف دست‌نخورده می‌ماند.")
return 0
if not batch:
batch = config.max_per_run or 20
interval = config.send_interval_seconds or 0
now = timezone.now()
# تنها پیامک‌هایی که مدیر صراحتاً تأیید کرده ارسال می‌شوند؛ موارد «در
# انتظار تأیید» دست‌نخورده در صف بازبینی می‌مانند.
candidates = SMSQueue.objects.filter(
status=SMSQueue.Status.APPROVED,
).filter(
Q(next_retry_at__isnull=True) | Q(next_retry_at__lte=now),
).order_by("created_at")[:batch]
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for item in candidates:
if self._stop:
break
# claim اتمیک مثبت‌گرا (SQLite از select_for_update پشتیبانی نمی‌کند).
claimed = SMSQueue.objects.filter(
pk=item.pk, status=SMSQueue.Status.APPROVED,
).update(status=SMSQueue.Status.PROCESSING)
if not claimed:
continue
try:
row = SMSQueue.objects.get(pk=item.pk)
except SMSQueue.DoesNotExist:
# رکورد بین انتخاب و claim حذف شده (مثلاً پاک‌سازی دستی پنل).
continue
# فاصلهٔ بین پیامک‌ها: پیش از ارسال مورد بعدی صبر می‌کنیم (نه بعد از آخرین).
if processed and interval:
self._sleep_interruptible(interval)
if self._stop:
# مورد claim‌شده را به صف برگردان تا از دست نرود.
SMSQueue.objects.filter(
pk=row.pk, status=SMSQueue.Status.PROCESSING,
).update(status=SMSQueue.Status.APPROVED)
break
processed += 1
self._send(provider, row)
return processed
def _sleep_interruptible(self, seconds):
"""خواب قابل‌قطع تا سیگنال توقف سریع پاسخ داده شود."""
remaining = float(seconds)
while remaining > 0 and not self._stop:
step = min(0.5, remaining)
time.sleep(step)
remaining -= step
def _send(self, provider, row):
try:
result = provider.send_message(row.phone_number, row.message)
except Exception as exc: # provider نباید worker را از پا دربیاورد
logger.exception("استثنای غیرمنتظره در ارسال پیامک #%s", row.pk)
result = None
error = str(exc)
else:
error = getattr(result, "error", "") or ""
result = result if getattr(result, "success", False) else None
now = timezone.now()
row.attempts = (row.attempts or 0) + 1
if result is not None:
row.status = SMSQueue.Status.SENT
row.sent_at = now
row.provider_id = getattr(result, "provider_id", "") or ""
row.provider_code = getattr(result, "provider_code", "") or ""
# شناسه (smsid) داریم ⇒ اهل رهگیری دلیوری؛ دستور sms_delivery پر می‌کند.
row.delivery_status = (
SMSQueue.DeliveryStatus.PENDING if row.provider_id else ""
)
row.last_error = ""
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=True,
provider_id=row.provider_id, provider_code=row.provider_code,
)
self._emit(f" ارسال موفق #{row.pk} -> {row.phone_number}")
return
row.last_error = error[:2000]
if row.attempts >= (row.max_attempts or 5):
row.status = SMSQueue.Status.FAILED
else:
# همچنان تأییدشده می‌ماند تا تلاش خودکار بعدی ادامه یابد.
row.status = SMSQueue.Status.APPROVED
backoff = min(3600, 30 * (2 ** row.attempts))
row.next_retry_at = now + timedelta(seconds=backoff)
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=False, error=row.last_error,
)
self._emit(f" ارسال ناموفق #{row.pk} (تلاش {row.attempts}): {row.last_error[:120]}", err=True)
+124
View File
@@ -0,0 +1,124 @@
# Generated by Django 3.2.25 on 2026-10-09 16:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0056_auto_20260731_1824'),
]
operations = [
migrations.CreateModel(
name='SMSProviderConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(choices=[('sms0098', '0098 (SOAP/zeep)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک')),
('is_enabled', models.BooleanField(default=True, help_text='در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی\u200cکند.', verbose_name='فعال بودن ارسال پیامک')),
('api_url', models.CharField(blank=True, default='', max_length=500, verbose_name='آدرس سرویس (WSDL یا URL)')),
('username', models.CharField(blank=True, default='', max_length=100, verbose_name='نام کاربری')),
('password', models.CharField(blank=True, default='', max_length=200, verbose_name='رمز عبور')),
('sender', models.CharField(blank=True, default='', max_length=50, verbose_name='شماره خط فرستنده')),
('api_token', models.CharField(blank=True, default='', max_length=255, verbose_name='توکن API (HTTP)')),
('timeout_seconds', models.PositiveIntegerField(default=30, verbose_name='مهلت ارتباط (ثانیه)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'تنظیمات سامانه پیامک',
'verbose_name_plural': 'تنظیمات سامانه پیامک',
'permissions': [('ManageSMS', 'مدیریت پیامک')],
},
),
migrations.CreateModel(
name='SMSTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, unique=True, verbose_name='نوع پیامک')),
('title', models.CharField(max_length=100, verbose_name='عنوان')),
('text', models.TextField(help_text='متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، {amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، {check_row} شماره ردیف چک، {owner_name} نام مالک چک.', verbose_name='متن پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('offset_days', models.PositiveSmallIntegerField(default=2, verbose_name='چند روز قبل از سررسید ارسال شود')),
('repeat_interval_days', models.PositiveSmallIntegerField(blank=True, help_text='خالی بگذارید برای ارسال یک\u200cباره؛ مقدار بدهید برای ارسال تکرارشونده.', null=True, verbose_name='فاصله تکرار (روز)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'قالب پیامک',
'verbose_name_plural': 'قالب\u200cهای پیامک',
},
),
migrations.AddField(
model_name='phonenumber',
name='is_primary',
field=models.BooleanField(default=False, verbose_name='شماره اصلی (گیرنده پیامک)'),
),
migrations.CreateModel(
name='SMSQueue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(blank=True, choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], default='', max_length=40, verbose_name='نوع')),
('phone_number', models.CharField(max_length=20, verbose_name='شماره گیرنده')),
('message', models.TextField(verbose_name='متن')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت')),
('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='تعداد تلاش')),
('max_attempts', models.PositiveSmallIntegerField(default=5, verbose_name='حداکثر تلاش')),
('next_retry_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان تلاش بعدی')),
('last_error', models.TextField(blank=True, default='', verbose_name='آخرین خطا')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('dedup_key', models.CharField(blank=True, help_text='کلید یکتاسازی برای جلوگیری از ارسال تکراری.', max_length=120, null=True, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sms_queue', to='admin_panel.customer', verbose_name='مشتری')),
],
options={
'verbose_name': 'صف پیامک',
'verbose_name_plural': 'صف پیامک\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SMSLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attempt_no', models.PositiveSmallIntegerField(default=1, verbose_name='شماره تلاش')),
('success', models.BooleanField(default=False, verbose_name='موفق')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('error', models.TextField(blank=True, default='', verbose_name='خطا')),
('created_at', models.DateTimeField(auto_now_add=True)),
('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='admin_panel.smsqueue', verbose_name='صف')),
],
options={
'verbose_name': 'لاگ پیامک',
'verbose_name_plural': 'لاگ\u200cهای پیامک',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CustomerSMSSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, verbose_name='نوع پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sms_settings', to='admin_panel.customer')),
],
options={
'verbose_name': 'تنظیم پیامک مشتری',
'verbose_name_plural': 'تنظیمات پیامک مشتریان',
},
),
migrations.AddIndex(
model_name='smsqueue',
index=models.Index(fields=['status', 'next_retry_at'], name='admin_panel_status_c227cc_idx'),
),
migrations.AlterUniqueTogether(
name='customersmssetting',
unique_together={('customer', 'sms_type')},
),
]
@@ -0,0 +1,115 @@
"""دادهٔ اولیه: متن پیش‌فرض قالب‌های پیامک + علامت‌گذاری شمارهٔ اصلی از فیلدهای قدیمی.
این مایگریشن idempotent است (get_or_create / update_if) و اجرای دوبارهٔ آن بی‌خطر است.
"""
from django.db import migrations
DEFAULT_TEMPLATES = [
{
"sms_type": "welcome",
"title": "خوش‌آمدگویی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.",
},
{
"sms_type": "cheque_due",
"title": "یادآوری سررسید چک",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید چک شما به مبلغ {amount_sep} ریال باقی مانده است. لطفاً نسبت به تأمین موجودی اقدام فرمایید.",
},
{
"sms_type": "nesiye_due",
"title": "یادآوری سررسید نسیه",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید بدهی شما به مبلغ {amount_sep} ریال باقی مانده است.",
},
{
"sms_type": "cheque_registration",
"title": "یادآوری تأمین موجودی چک",
"offset_days": 2,
"repeat_interval_days": 3,
"text": "{name} عزیز، سررسید چک شما ({check_row}) نزدیک است. لطفاً موجودی بانکی حساب خود را تأمین فرمایید.",
},
{
"sms_type": "debt_due",
"title": "یادآوری بدهی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، مبلغ {amount_sep} ریال بدهی شما باقی مانده است. لطفاً نسبت به تسویه اقدام فرمایید.",
},
{
"sms_type": "visit",
"title": "یادآوری بازدید خدمات",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، زمان بازدید دوره‌ای دستگاه شما فرا رسیده است. برای تعیین وقت با ما تماس بگیرید.",
},
{
"sms_type": "survey",
"title": "نظرسنجی",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، نظر شما دربارهٔ خدمات ما برایمان ارزشمند است. لطفاً در نظرسنجی شرکت فرمایید.",
},
{
"sms_type": "custom",
"title": "پیام سفارشی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "",
},
]
def seed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for item in DEFAULT_TEMPLATES:
SMSTemplate.objects.get_or_create(
sms_type=item["sms_type"],
defaults={
"title": item["title"],
"text": item["text"],
"is_enabled": True,
"offset_days": item["offset_days"],
"repeat_interval_days": item["repeat_interval_days"],
},
)
def unseed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.filter(
sms_type__in=[t["sms_type"] for t in DEFAULT_TEMPLATES]
).delete()
def backfill_primary_phones(apps, schema_editor):
"""اگر هیچ شمارهٔ اصلی‌ای ثبت نشده، شمارهٔ مطابق phone1 هر مشتری را اصلی می‌کند."""
Customer = apps.get_model("admin_panel", "Customer")
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer in Customer.objects.all():
if PhoneNumber.objects.filter(customer=customer, is_primary=True).exists():
continue
phone1 = (customer.phone1 or "").strip()
if not phone1:
continue
PhoneNumber.objects.filter(customer=customer, phone_number=phone1).update(is_primary=True)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0057_sms"),
]
operations = [
migrations.RunPython(seed_templates, unseed_templates),
migrations.RunPython(backfill_primary_phones, noop),
]
@@ -0,0 +1,49 @@
# Generated by Django 3.2.25 on 2026-10-09 17:08
from django.db import migrations, models
def stamp_scan_since(apps, schema_editor):
"""روی نصب‌های موجود، خط شروع را روی «همین حالا» مهر می‌کند.
نتیجه: پس از به‌روزرسانی، موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نمی‌گیرند.
"""
from django.utils import timezone
SMSProviderConfig = apps.get_model('admin_panel', 'SMSProviderConfig')
SMSProviderConfig.objects.filter(scan_since__isnull=True).update(scan_since=timezone.now())
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0058_seed_sms_templates'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «خط شروع» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='backlog_max',
field=models.PositiveIntegerField(default=0, help_text='فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.', verbose_name='حداکثر موارد قدیمی در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='max_per_run',
field=models.PositiveIntegerField(default=200, help_text='سقف کلی صف\u200cکردن برای هر نوع پیامک در هر اجرای اسکنر.', verbose_name='حداکثر پیامک هر اسکنر در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فقط مواردی که پنجرهٔ یادآوری\u200cشان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (قبل از این لحظه) نادیده گرفته می\u200cشوند. اگر خالی باشد، در نخستین اجرا به\u200cصورت خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='خط شروع ارسال'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='send_interval_seconds',
field=models.PositiveIntegerField(default=3, help_text='worker پس از هر ارسال این مدت صبر می\u200cکند تا سرویس\u200cدهنده زیر بار نرود.', verbose_name='فاصلهٔ بین هر پیامک (ثانیه)'),
),
migrations.RunPython(stamp_scan_since, migrations.RunPython.noop),
]
@@ -0,0 +1,32 @@
# Generated by Django 3.2.25 on 2026-10-09 17:15
from django.db import migrations
def rename_manage_sms_permission(apps, schema_editor):
"""نام نمایشی پرمیشن را با قرارداد پروژه هم‌راستا می‌کند.
Django هنگام ``AlterModelOptions`` رکورد Permission موجود را تغییر نمی‌دهد؛
پس نام قبلی (فارسی) روی نصب‌های موجود باقی می‌ماند و باید صریحاً به‌روزرسانی شود.
"""
Permission = apps.get_model('auth', 'Permission')
Permission.objects.filter(
content_type__app_label='admin_panel',
content_type__model='smsproviderconfig',
codename='ManageSMS',
).update(name='ManageSMS')
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0059_sms_flow_control'),
]
operations = [
migrations.AlterModelOptions(
name='smsproviderconfig',
options={'permissions': [('ManageSMS', 'ManageSMS')], 'verbose_name': 'تنظیمات سامانه پیامک', 'verbose_name_plural': 'تنظیمات سامانه پیامک'},
),
migrations.RunPython(rename_manage_sms_permission, migrations.RunPython.noop),
]
@@ -0,0 +1,119 @@
"""یکسان‌سازی شماره‌های اصلی + زمان‌بندی اختصاصی هر نوع پیامک.
* «شمارهٔ اصلی» از رکورد ``PhoneNumber`` به خودِ ``Customer`` منتقل می‌شود و با مقدارِ
نرمال‌شدهٔ شماره ذخیره می‌گردد تا با افزودن/حذف/ویرایش مداوم مشتریان پایدار بماند.
* فیلد زمانی هر قالب با معنای واقعی همان نوع هم‌راستا می‌شود (مثلاً نظرسنجی = ۳ روز بعد
از صدور فاکتور، و یادآوری ثبت چک = هر ۳ روز تا ثبت اطلاعات چک).
"""
from django.db import migrations, models
def _normalize(raw):
"""نرمال‌سازی شماره (بدون وابستگی به کد اپ، برای اجرای امن روی دادهٔ قدیمی)."""
from integrations.sms.normalize import normalize_mobile
return normalize_mobile(raw)
def backfill_primary_phones(apps, schema_editor):
"""پرچم‌های ``is_primary`` موجود را به مقدار نرمال‌شده روی مشتری منتقل می‌کند."""
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer_id, phone_number in (
PhoneNumber.objects.filter(is_primary=True)
.values_list("customer_id", "phone_number")
):
normalized = _normalize((phone_number or "").strip())
if not normalized:
continue
customer = apps.get_model("admin_panel", "Customer").objects.filter(pk=customer_id).first()
if customer is None:
continue
current = [p for p in (customer.sms_primary_phones or "").split(",") if p]
if normalized in current:
continue
current.append(normalized)
customer.sms_primary_phones = ",".join(current)
customer.save(update_fields=["sms_primary_phones"])
def normalize_template_timing(apps, schema_editor):
"""مقادیر زمانیِ بی‌معنی را با معنای درست هر نوع پیامک هم‌راستا می‌کند.
فقط مقادیری که برای آن نوع معنا ندارند اصلاح می‌شوند؛ تنظیمات معنادارِ مدیر دست‌نخورده
می‌ماند.
"""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
# نظرسنجی: «۳ روز بعد از صدور فاکتور» (مقدار پیشین ۲ بود).
SMSTemplate.objects.filter(sms_type="survey", offset_days=2).update(offset_days=3)
# یادآوری ثبت چک: فاصله تکرار ۳ روز و بدون offset سررسید.
for template in SMSTemplate.objects.filter(sms_type="cheque_registration"):
template.offset_days = 0
if not template.repeat_interval_days:
template.repeat_interval_days = 3
template.save(update_fields=["offset_days", "repeat_interval_days"])
# یادآوری بازدید: دورهٔ پیش‌فرض ۹ ماه (قابل تغییر از پنل).
SMSTemplate.objects.filter(
sms_type="visit", repeat_interval_months__isnull=True,
).update(repeat_interval_months=9)
# متن «یادآوری ثبت چک» به معنای جدید (ثبت اطلاعات چک) هم‌راستا می‌شود.
SMSTemplate.objects.filter(sms_type="cheque_registration").update(
title="یادآوری ثبت اطلاعات چک",
text="{name} عزیز، برای چک با سررسید {date} هنوز اطلاعات ثبت چک "
"(شمارهٔ صیادی) در سامانه ثبت نشده است. لطفاً نسبت به ثبت آن اقدام فرمایید.",
)
# سایر انواع: هیچ فیلد زمانی نباید مقدار داشته باشد.
SMSTemplate.objects.filter(
sms_type__in=["welcome", "debt_due", "custom"],
).update(offset_days=0, repeat_interval_days=None, repeat_interval_months=None)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0060_sms_permission_label"),
]
operations = [
migrations.AddField(
model_name="customer",
name="sms_primary_phones",
field=models.CharField(
blank=True, default="", max_length=100,
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
),
),
# ترتیب مهم است: ابتدا مقادیر فعلی خوانده و منتقل می‌شوند، سپس ستون قدیمی حذف می‌گردد.
migrations.RunPython(backfill_primary_phones, noop),
migrations.RemoveField(model_name="phonenumber", name="is_primary"),
migrations.AddField(
model_name="smstemplate",
name="repeat_interval_months",
field=models.PositiveSmallIntegerField(
blank=True, null=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. "
"خالی = پیش‌فرض سامانه (۹ ماه).",
),
),
migrations.AlterField(
model_name="smstemplate",
name="offset_days",
field=models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده "
"می‌شود؛ مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
),
),
migrations.RunPython(normalize_template_timing, noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-09 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0061_sms_timing_and_primary'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='api_url',
field=models.CharField(blank=True, default='http://www.0098sms.com/sendsmslink.aspx', help_text='برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.', max_length=500, verbose_name='آدرس سرویس پیامک (URL)'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 06:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0062_sms_link_provider'),
]
operations = [
migrations.AlterField(
model_name='smsqueue',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار تأیید'), ('approved', 'تأییدشده'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 3.2.25 on 2026-10-10 07:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0063_alter_smsqueue_status'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='panel_no',
field=models.CharField(blank=True, default='', help_text='شمارهٔ اختصاصی پنل در وب\u200cسرویس؛ اگر خالی بماند از «شماره خط فرستنده» استفاده می\u200cشود.', max_length=50, verbose_name='شماره پنل (PnlNo)'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='webservice_url',
field=models.CharField(blank=True, default='https://webservice.0098sms.com/service.asmx', help_text='فقط برای provider «وب\u200cسرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام دلیوری از همین آدرس انجام می\u200cشود.', max_length=500, verbose_name='آدرس وب\u200cسرویس (SOAP/WSDL)'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_checked_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='زمان آخرین استعلام دلیوری'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_status',
field=models.CharField(blank=True, choices=[('pending', 'در انتظار استعلام'), ('delivered', 'رسیده به گوشی'), ('not_delivered', 'نرسیده به گوشی'), ('expired', 'منقضی\u200cشده'), ('unknown', 'نامشخص')], db_index=True, default='', help_text='خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می\u200cشود.', max_length=16, verbose_name='وضعیت تحویل'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('sms0098ws', '0098SMS (وب\u200cسرویس + استعلام دلیوری)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 3.2.25 on 2026-10-10 07:48
from django.db import migrations, models
def scrub_brand(apps, schema_editor):
"""حذف نام برند از متن قالب‌هایی که پیش‌تر با «کتیبه» ذخیره شده‌اند."""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for template in SMSTemplate.objects.filter(text__contains="کتیبه"):
cleaned = (
template.text.replace("به خانواده کتیبه خوش آمدید. ", "")
.replace(" نزد کتیبه", "")
)
template.text = cleaned
template.save(update_fields=["text"])
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0064_sms_webservice_delivery'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='test_message',
field=models.TextField(blank=True, default='پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.', help_text='متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.', verbose_name='متن پیامک آزمایشی'),
),
migrations.RunPython(scrub_brand, noop),
]
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 3.2.25 on 2026-10-10 08:05
from django.db import migrations, models
def backfill_keys(apps, schema_editor):
"""کلیدهای صفِ موجود را به دفتر منتقل می‌کند تا حذف بعدی‌شان باعث بازگشت نشود."""
SMSQueue = apps.get_model("admin_panel", "SMSQueue")
SMSDedup = apps.get_model("admin_panel", "SMSDedup")
keys = SMSQueue.objects.exclude(dedup_key__isnull=True).exclude(
dedup_key=""
).values_list("dedup_key", flat=True)
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0065_smsproviderconfig_test_message'),
]
operations = [
migrations.CreateModel(
name='SMSDedup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=120, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'کلید پردازش\u200cشدهٔ پیامک',
'verbose_name_plural': 'کلیدهای پردازش\u200cشدهٔ پیامک',
},
),
migrations.RunPython(backfill_keys, noop),
]
@@ -0,0 +1,30 @@
# Generated by Django 3.2.25 on 2026-10-10 08:43
from django.db import migrations, models
def scrub_legacy_phones(apps, schema_editor):
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن قدیمی (idempotent).
همان قاعدهٔ ``admin_panel.sms.phone_cleanup``؛ اینجا فقط مدل از رجیستری
تاریخی گرفته می‌شود. اجرای دوباره بی‌اثر است.
"""
from admin_panel.sms.phone_cleanup import clean_all
clean_all(apps=apps)
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0066_smsdedup'),
]
operations = [
migrations.AddField(
model_name='customer',
name='notes',
field=models.TextField(blank=True, default='', help_text='یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.', verbose_name='توضیحات'),
),
migrations.RunPython(scrub_legacy_phones, migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-10 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0067_customer_notes'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است تا مشتریان قدیمی مشمول این فیلتر شوند.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (مثل مشتریان خیلی قدیمی که مدت\u200cهاست بازدید نشده\u200cاند) نادیده گرفته می\u200cشوند. از پنل به شکل تاریخ شمسی ویرایش می\u200cشود و روی «آغاز همان روز» تنظیم می\u200cگردد. اگر خالی باشد، در نخستین اجرا خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='تاریخ شروع دوره'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0068_auto_20261010_1305'),
]
operations = [
migrations.AddField(
model_name='smstemplate',
name='auto_send',
field=models.BooleanField(default=False, help_text='اگر خاموش باشد (پیش\u200cفرض)، پیامک\u200cهای این نوع در وضعیت «در انتظار تأیید» در صف می\u200cمانند تا مدیر آن\u200cها را تأیید کند. با روشن\u200cکردن، پیامکِ ساخته\u200cشده توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می\u200cشود.', verbose_name='ارسال خودکار (بدون تأیید مدیر)'),
),
]
@@ -0,0 +1,174 @@
"""کاهش انواع پیامک به همان ۷ نوع مصوب + هم‌راستاسازی قالب‌ها.
* سه نوع قدیمی (``cheque_due``، ``debt_due``، ``custom``) حذف می‌شوند:
قالب‌ها و تنظیمات opt-out مربوط به آن‌ها پاک می‌شود.
* دو نوع جدید اضافه می‌شود: ``visit_created`` (ثبت اقدام بازدید) و
``payment_thanks`` (تشکر از پرداخت).
* متن/زمان‌بندی نوع‌های باقی‌مانده به معنای جدید هم‌راستا می‌شود
(مثلاً «تأمین موجودی چک» = چند روز پس از سررسید، و نظرسنجی = بی‌درنگ پس از فاکتور).
"""
from django.db import migrations, models
SMS_TYPE_CHOICES = [
("welcome", "خوش‌آمدگویی (ایجاد مشتری)"),
("survey", "نظرسنجی پس از ثبت فاکتور"),
("cheque_registration", "یادآوری تأمین موجودی چک"),
("visit", "یادآوری بازدید خدمات دستگاه"),
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
("payment_thanks", "تشکر از پرداخت"),
("nesiye_due", "یادآوری سررسید نسیه"),
]
REMOVED_TYPES = ["cheque_due", "debt_due", "custom"]
# نوع → مقادیر پیش‌فرض (خالی‌نماندن قالب‌های جدید و هم‌راستایی نوع‌های تغییریافته).
DEFAULT_TEMPLATES = {
"welcome": {
"title": "خوش‌آمدگویی",
"text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"survey": {
"title": "نظرسنجی پس از فاکتور",
"text": "{name} عزیز، از خرید شما سپاسگزاریم. لطفاً با ثبت نظر خود در "
"نظرسنجی ما را همراهی کنید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"cheque_registration": {
"title": "یادآوری تأمین موجودی چک",
"text": "{name} عزیز، چک شما به مبلغ {amount_sep} ریال سررسید شده است. "
"لطفاً نسبت به تأمین موجودی حساب اقدام فرمایید.",
"offset_days": 1,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"visit": {
"title": "یادآوری بازدید خدمات دستگاه",
"text": "{name} عزیز، بیش از ۹ ماه از تعویض فیلتر دستگاه شما گذشته است. "
"لطفاً جهت هماهنگی برای ثبت بازدید رایگان با تلفن 09139741111 "
"تماس بگیرید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": 9,
},
"visit_created": {
"title": "ثبت بازدید",
"text": "{name} عزیز، بازدید شما برای تاریخ {date} ثبت شد. "
"از همکاری شما سپاسگزاریم.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"payment_thanks": {
"title": "تشکر از پرداخت",
"text": "{name} عزیز، از پرداخت شما به مبلغ {amount_sep} ریال سپاسگزاریم.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"nesiye_due": {
"title": "یادآوری سررسید نسیه",
"text": "{name} عزیز، سررسید بدهی شما به مبلغ {amount_sep} ریال نزدیک است "
"({date}). لطفاً نسبت به تسویه اقدام فرمایید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
}
def align_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
CustomerSMSSetting = apps.get_model("admin_panel", "CustomerSMSSetting")
# ۱. پاک‌سازی انواع حذف‌شده.
SMSTemplate.objects.filter(sms_type__in=REMOVED_TYPES).delete()
CustomerSMSSetting.objects.filter(sms_type__in=REMOVED_TYPES).delete()
# ۲. ایجاد/به‌روزرسانی قالب‌های ۷ نوع مجاز (متن پیش‌فرض جدید + زمان‌بندی).
for sms_type, data in DEFAULT_TEMPLATES.items():
_template, created = SMSTemplate.objects.get_or_create(
sms_type=sms_type,
defaults={
"title": data["title"],
"text": data["text"],
"is_enabled": True,
"offset_days": data["offset_days"],
"repeat_interval_days": data["repeat_interval_days"],
"repeat_interval_months": data["repeat_interval_months"],
},
)
if not created:
SMSTemplate.objects.filter(sms_type=sms_type).update(
offset_days=data["offset_days"],
repeat_interval_days=data["repeat_interval_days"],
repeat_interval_months=data["repeat_interval_months"],
)
# ۳. متن/عنوانِ نوع‌های تغییریافته به معنای جدید هم‌راستا می‌شود.
SMSTemplate.objects.filter(sms_type="visit").update(
title=DEFAULT_TEMPLATES["visit"]["title"],
text=DEFAULT_TEMPLATES["visit"]["text"],
)
SMSTemplate.objects.filter(sms_type="cheque_registration").update(
title=DEFAULT_TEMPLATES["cheque_registration"]["title"],
text=DEFAULT_TEMPLATES["cheque_registration"]["text"],
)
SMSTemplate.objects.filter(sms_type="survey").update(
title=DEFAULT_TEMPLATES["survey"]["title"],
text=DEFAULT_TEMPLATES["survey"]["text"],
)
def clear_removed_queue_types(apps, schema_editor):
"""نوعِ ردیف‌های صفِ باقی‌مانده از انواع حذف‌شده را خالی می‌کند.
ردیف صف حذف نمی‌شود (متن و تاریخچه‌اش می‌ماند) چون ممکن است هنوز ارسال‌نشده
باشد؛ فقط ``sms_type`` آن خالی می‌شود تا کدِ نوعِ نامعتبر در دیتابیس نماند.
"""
SMSQueue = apps.get_model("admin_panel", "SMSQueue")
SMSQueue.objects.filter(sms_type__in=REMOVED_TYPES).update(sms_type="")
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0069_smstemplate_auto_send"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, unique=True,
verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="customersmssetting",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="smsqueue",
name="sms_type",
field=models.CharField(
blank=True, choices=SMS_TYPE_CHOICES, default="", max_length=40,
verbose_name="نوع",
),
),
migrations.RunPython(align_templates, noop),
migrations.RunPython(clear_removed_queue_types, noop),
]
@@ -0,0 +1,47 @@
"""افزودن پرمیشن جداگانهٔ ``ManageSMSSettings`` برای صفحات تنظیمات/قالب‌های پیامک.
پیش از این، همهٔ مسیرهای پیامک با ``ManageSMS`` کنترل می‌شد. از این پس صفحات
«تنظیمات سامانه» و «قالب‌ها» با ``ManageSMSSettings`` جدا می‌شوند تا مدیر بتواند
دسترسی به آن‌ها را فقط به کاربران مشخصی بدهد، بدون دادن کل اختیارات پیامک.
"""
from django.db import migrations
from django.db.models import F
def ensure_permission(apps, schema_editor):
"""رکورد Permission جدید را (اگر نبود) می‌سازد و نام نمایشی‌اش را اصلاح می‌کند."""
Permission = apps.get_model('auth', 'Permission')
ContentType = apps.get_model('contenttypes', 'ContentType')
content_type, _created = ContentType.objects.get_or_create(
app_label='admin_panel', model='smsproviderconfig',
)
Permission.objects.get_or_create(
content_type=content_type,
codename='ManageSMSSettings',
defaults={'name': 'ManageSMSSettings'},
)
# نام نمایشی هر دو پرمیشن پیامک را با قرارداد پروژه هم‌راستا می‌کند.
Permission.objects.filter(
content_type=content_type, codename__in=['ManageSMS', 'ManageSMSSettings'],
).update(name=F('codename'))
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0070_sms_types_seven'),
]
operations = [
migrations.AlterModelOptions(
name='smsproviderconfig',
options={
'permissions': [('ManageSMS', 'ManageSMS'),
('ManageSMSSettings', 'ManageSMSSettings')],
'verbose_name': 'تنظیمات سامانه پیامک',
'verbose_name_plural': 'تنظیمات سامانه پیامک',
},
),
migrations.RunPython(ensure_permission, migrations.RunPython.noop),
]
@@ -0,0 +1,35 @@
# Generated by Django 3.2.25 on 2026-10-15 06:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0071_sms_settings_permission'),
]
operations = [
migrations.CreateModel(
name='CustomerNameFixJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_hash', models.CharField(max_length=64)),
('algorithm_version', models.CharField(default='1', max_length=16)),
('status', models.CharField(choices=[('running', 'در حال اجرا'), ('paused', 'متوقف\u200cشده (قابل ادامه)'), ('completed', 'تکمیل\u200cشده'), ('failed', 'ناموفق')], default='paused', max_length=16)),
('batch_size', models.PositiveIntegerField(default=200)),
('initial_max_pk', models.BigIntegerField(blank=True, null=True)),
('last_processed_pk', models.BigIntegerField(default=0)),
('stats', models.JSONField(blank=True, default=dict)),
('note', models.CharField(blank=True, default='', max_length=255)),
('started_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('heartbeat_at', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name': 'job اصلاح نام مشتری',
'verbose_name_plural': 'jobهای اصلاح نام مشتری',
},
),
]
@@ -0,0 +1,37 @@
# Generated by Django 3.2.25 on 2026-10-16 10:49
from django.db import migrations, models
class Migration(migrations.Migration):
"""افزودن ``choices`` به ``Action.actionType``.
این migration فقط وضعیت مدل را به‌روز می‌کند: Django ``choices`` را در
``deconstruct()`` فیلد نگه می‌دارد، پس بدون این migration دستور
``makemigrations --check`` شکست می‌خورد. تعریف ستون در SQLite هیچ تغییری
نمی‌کند (``choices`` در DDL بازتاب نمی‌یابد) و هیچ داده‌ای بازنویسی نمی‌شود.
"""
dependencies = [
('admin_panel', '0072_customernamefixjob'),
]
operations = [
migrations.AlterField(
model_name='action',
name='actionType',
field=models.CharField(
blank=True,
choices=[
('bazdid', 'بازدید'),
('nasb', 'نصب'),
('vosol', 'وصول مطالبات'),
('hamahangi', 'هماهنگی'),
('inCall', 'تماس ورودی'),
('outCall', 'تماس خروجی'),
],
max_length=50,
null=True,
),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 3.2.25 on 2026-10-16 10:49
from django.db import migrations, models
class Migration(migrations.Migration):
"""افزودن ``choices`` به ``Action.status``.
این migration فقط وضعیت مدل را به‌روز می‌کند: Django ``choices`` را در
``deconstruct()`` فیلد نگه می‌دارد، پس بدون این migration دستور
``makemigrations --check`` شکست می‌خورد. تعریف ستون در SQLite هیچ تغییری
نمی‌کند (``choices`` در DDL بازتاب نمی‌یابد) و هیچ داده‌ای بازنویسی نمی‌شود.
املای مقادیر عیناً همان چیزی است که در دیتابیس و کد موجود به‌کار رفته:
``'waite'`` (نه ``'wait'``)، ``'finish'`` و ``'cancell'``.
"""
dependencies = [
('admin_panel', '0073_alter_action_actiontype_choices'),
]
operations = [
migrations.AlterField(
model_name='action',
name='status',
field=models.CharField(
blank=True,
choices=[
('waite', 'در انتظار (باز)'),
('finish', 'انجام شده'),
('cancell', 'لغو شده'),
],
max_length=50,
null=True,
),
),
]
@@ -0,0 +1,49 @@
"""یادآوری تأمین موجودی چک: تغییر جهت از «پس از سررسید» به «پیش از سررسید».
هدف این migration **فقط** هم‌راستا‌کردن مقدار عددی ``offset_days`` قالب
``cheque_registration`` با معنای جدید است (تعداد روز **قبل** از سررسید).
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن پیامک) را تغییر نمی‌دهد — متنی که مدیر در پنل تنظیم کرده دست‌نخورده
می‌ماند. اگر متن فعلی از زمان گذشته حرف می‌زند («سررسید شده است»)، مدیر می‌تواند
خودش از صفحهٔ «قالب‌های پیامک» ویرایشش کند.
* ``title``، ``is_enabled`` و ``auto_send`` را تغییر نمی‌دهد.
* هیچ نوع/قالب دیگری را لمس نمی‌کند.
مقدار ``offset_days`` روی پیش‌فرض معنای جدید (۱ روز قبل از سررسید) تنظیم می‌شود تا
نصب‌های تازه و نصب‌های موجود یکسان رفتار کنند؛ اما اگر مدیر قبلاً عددی غیر از مقدار
قدیمی (``1``) گذاشته باشد، به آن دست نمی‌زنیم تا انتخابش از بین نرود.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations
SMS_TYPE = "cheque_registration"
# مقدار پیش‌فرضِ منطق قدیم («۱ روز پس از سررسید») که با معنای جدید هم‌عددی است؛
# فقط رکوردهایی که هنوز روی همین مقدار هستند هم‌راستا می‌شوند.
OLD_DEFAULT = 1
NEW_DEFAULT = 1
def align_cheque_offset(apps, schema_editor):
"""``offset_days`` قالب چک را روی پیش‌فرض جدید بگذارد (بدون لمس متن)."""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.filter(
sms_type=SMS_TYPE, offset_days=OLD_DEFAULT,
).update(offset_days=NEW_DEFAULT)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0074_alter_action_status_choices"),
]
operations = [
migrations.RunPython(align_cheque_offset, noop),
]
@@ -0,0 +1,36 @@
"""افزودن متغیر ``{sayyad_id}`` به راهنمای ``help_text`` متن قالب پیامک.
هدف: راهنمای متغیرها در پنل، «شناسهٔ صیاد چک» را هم نشان دهد.
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن واقعی قالب‌ها) را تغییر نمی‌دهد — فقط ``help_text`` فیلد عوض شده
است که در ``deconstruct()`` می‌آید و state مدل را جلو می‌برد.
* هیچ رکورد ``SMSTemplate`` را لمس نمی‌کند.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0075_cheque_funding_before_due"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="text",
field=models.TextField(
help_text=(
"متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، "
"{days} تعداد روز، {check_row} شماره ردیف چک، "
"{sayyad_id} شناسه صیاد چک، {owner_name} نام مالک چک."
),
verbose_name="متن پیامک",
),
),
]
@@ -0,0 +1,39 @@
"""افزودن متغیرهای ``{balance}`` / ``{balance_sep}`` به راهنمای متن قالب پیامک.
هدف: راهنمای متغیرها در پنل، «مانده حساب مشتری» را هم نشان دهد و تفاوتش را با
مبلغ سررسیدشده روشن کند.
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن واقعی قالب‌ها) را تغییر نمی‌دهد — فقط ``help_text`` فیلد عوض شده
است که در ``deconstruct()`` می‌آید و state مدل را جلو می‌برد.
* هیچ رکورد ``SMSTemplate`` را لمس نمی‌کند.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations, models
HELP_TEXT = (
"متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {sayyad_id} شناسه صیاد چک، "
"{owner_name} نام مالک چک، {balance} مانده حساب مشتری، "
"{balance_sep} مانده حساب با جداکننده. "
"(توجه: {balance} مانده کل حساب است و با مبلغ سررسیدشدهٔ چک/نسیه "
"که در {amount} می‌آید تفاوت دارد.)"
)
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0076_alter_smstemplate_text_help"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="text",
field=models.TextField(help_text=HELP_TEXT, verbose_name="متن پیامک"),
),
]
@@ -0,0 +1,36 @@
# Generated by Django 3.2.25 on 2026-10-20 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0077_alter_smstemplate_text_balance_help'),
]
operations = [
# ایندکس سه‌ستونیِ قبلی باید ابتدا برداشته شود؛ وگرنه نام ``act_visit_idx``
# تکراری می‌شود. ساخت ایندکس پوشا هم همین‌جا انجام می‌شود تا وضعیت مدل
# (``Meta.indexes``) و دیتابیس دقیقاً یکی باشند.
migrations.RunSQL(
sql=[
'DROP INDEX IF EXISTS "act_visit_idx";',
(
'CREATE INDEX "act_visit_idx" ON "admin_panel_action" '
'("actionType", "status", "customer_id", "actionDateTime", "finishDateTime");'
),
],
reverse_sql=['DROP INDEX IF EXISTS "act_visit_idx";'],
state_operations=[
migrations.AddIndex(
model_name='action',
index=models.Index(
fields=['actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime'],
name='act_visit_idx',
),
),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-20 18:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0078_action_visit_covering_index'),
]
operations = [
migrations.AddField(
model_name='customer',
name='visit_hold_set_at',
field=models.DateTimeField(blank=True, help_text='برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.', null=True, verbose_name='زمان ثبت قرار'),
),
migrations.AddField(
model_name='customer',
name='visit_hold_until',
field=models.DateTimeField(blank=True, help_text='تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم می\u200cشود.', null=True, verbose_name='قرار بعدی (تعویق بازدید)'),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2026-10-21 12:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0079_customer_visit_hold'),
]
operations = [
migrations.AddField(
model_name='action',
name='deleted_at',
field=models.DateTimeField(blank=True, default=None, help_text='اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).', null=True, verbose_name='زمان حذف'),
),
migrations.AddField(
model_name='action',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_actions', to=settings.AUTH_USER_MODEL, verbose_name='حذف\u200cکننده'),
),
]
@@ -0,0 +1,113 @@
"""افزودن نوع پیامک ``install_created`` («ثبت نصب — اقدام از نوع نصب»).
**این مهاجرت هیچ دادهٔ موجودی را تغییر نمی‌دهد.** سه کار انجام می‌شود:
۱. ``choices`` سه فیلد ``sms_type`` (قالب، تنظیم مشتری، صف) به ۸ نوع گسترش
می‌یابد تا مقدار جدید در پنل/اعتبارسنجی معتبر باشد. ``AlterField`` فقط
متادیتای ستون را عوض می‌کند و به ارزش ردیف‌ها دست نمی‌زند.
۲. **فقط** یک قالب تازه برای نوع جدید ساخته می‌شود (``get_or_create``): اگر از
قبل وجود داشته باشد، هیچ فیلدی از آن بازنویسی نمی‌شود.
* ``is_enabled=True`` ⇒ از پنل قالب‌ها دیده/ویرایش شود.
* ``auto_send=False`` (صریح) ⇒ در صف «در انتظار تأیید» می‌ماند تا مدیر خودش
تأیید/ارسال کند؛ هیچ پیامکی ناخواسته ارسال نمی‌شود.
۳. قالب‌های هفت نوع قبلی حتی لمس هم نمی‌شوند: در این فایل هیچ ``update``/``save``
روی آن‌ها وجود ندارد.
متغیرهای متنی همان ``{name}`` و ``{date}`` هستند؛ نیازی به تغییر ساختار نیست.
"""
from django.db import migrations, models
SMS_TYPE_CHOICES = [
("welcome", "خوش‌آمدگویی (ایجاد مشتری)"),
("survey", "نظرسنجی پس از ثبت فاکتور"),
("cheque_registration", "یادآوری تأمین موجودی چک"),
("visit", "یادآوری بازدید خدمات دستگاه"),
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
("install_created", "ثبت نصب (اقدام از نوع نصب)"),
("payment_thanks", "تشکر از پرداخت"),
("nesiye_due", "یادآوری سررسید نسیه"),
]
NEW_SMS_TYPE = "install_created"
# قالب پیش‌فرض نوع جدید. متن مشابه «ثبت بازدید» است تا لحن پیام‌ها یکدست بماند.
INSTALL_TEMPLATE = {
"title": "ثبت نصب",
"text": "{name} عزیز، نصب شما برای تاریخ {date} ثبت شد. "
"از همکاری شما سپاسگزاریم.",
"is_enabled": True,
# خاموش: پیامک در «در انتظار تأیید» می‌ماند (رفتار پیش‌فرض امن پروژه).
"auto_send": False,
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
}
def seed_install_template(apps, schema_editor):
"""ایجاد قالب «ثبت نصب» اگر وجود ندارد (اجرای دوباره بی‌خطر است).
``get_or_create`` عمداً بدون هیچ ``update`` است: قالب‌های موجود (هر هفت نوع
قبلی) و حتی خودِ ``install_created`` اگر از قبل باشد، دست‌نخورده می‌مانند.
"""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.get_or_create(
sms_type=NEW_SMS_TYPE,
defaults={
"title": INSTALL_TEMPLATE["title"],
"text": INSTALL_TEMPLATE["text"],
"is_enabled": INSTALL_TEMPLATE["is_enabled"],
"auto_send": INSTALL_TEMPLATE["auto_send"],
"offset_days": INSTALL_TEMPLATE["offset_days"],
"repeat_interval_days": INSTALL_TEMPLATE["repeat_interval_days"],
"repeat_interval_months": INSTALL_TEMPLATE["repeat_interval_months"],
},
)
def drop_install_template(apps, schema_editor):
"""بازگشت: فقط ردیف‌های همان نوع جدید حذف می‌شوند.
سایر قالب‌ها و تنظیم‌های مشتری دست نمی‌خورند.
"""
apps.get_model("admin_panel", "SMSTemplate").objects.filter(
sms_type=NEW_SMS_TYPE,
).delete()
apps.get_model("admin_panel", "CustomerSMSSetting").objects.filter(
sms_type=NEW_SMS_TYPE,
).delete()
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0080_action_soft_delete"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, unique=True,
verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="customersmssetting",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="smsqueue",
name="sms_type",
field=models.CharField(
blank=True, choices=SMS_TYPE_CHOICES, default="", max_length=40,
verbose_name="نوع",
),
),
migrations.RunPython(seed_install_template, drop_install_template),
]
-515
View File
@@ -1,515 +0,0 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
# from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db import transaction, connection # اصلاح فیکس خطای تراکنش
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.core.validators import RegexValidator
userModel = get_user_model()
# اصلاح مدل کاربر: تبدیل به پروفایل برای حفظ امنیت و اتصال به سیستم پیش‌فرض جنگو
class UserProfile(models.Model):
UserTypeList = [
('admin', 'admin'),
('office', 'office'),
('operator', 'operator'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
user_type = models.CharField(max_length=10, choices=UserTypeList)
name_family = models.CharField(max_length=200)
create_date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.user.username} ({self.user_type})"
class Customer(models.Model):
class Meta:
permissions = [
("AddCustomer", "AddCustomer"),
("DelCustomer", "DelCustomer"),
("EditCustomer", "EditCustomer"),
("ViewCustomer", "ViewCustomer")
]
name_family = models.CharField(max_length=200)
address = models.CharField(max_length=200)
phone1 = models.CharField(max_length=20, default='')
phone2 = models.CharField(max_length=20, default='', null=True, blank=True)
phone3 = models.CharField(max_length=20, default='', null=True, blank=True)
idCode = models.CharField(max_length=20, default='', null=True, blank=True)
installDate = models.DateField(auto_now_add=True)
create_date = models.DateTimeField(auto_now_add=True)
last_bazdid_dt = models.DateTimeField(null=True, blank=True)
laghv_bazdid = models.BooleanField(null=True, blank=True, default=False)
# فیلد جدید برای حذف نرم (بدون تداخل با داده‌های قبلی)
is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری")
def __str__(self):
return self.name_family
# بازنویسی متد حذف: جلوگیری از فاجعه در دیتابیس
def delete(self, *args, **kwargs):
# به جای حذف فیزیکی که انبار و فاکتورها را نابود می‌کند، مشتری را غیرفعال می‌کنیم
self.is_active = False
self.save()
class PhoneNumber(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones')
title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)")
phone_number = models.CharField(max_length=20)
def __str__(self):
return f"{self.title}: {self.phone_number}"
class StoreRoom(models.Model):
class Meta:
permissions = [
("ViewStore", "ViewStore"),
("AddStore", "AddStore"),
("EditStore", "EditStore"),
("DelStore", "DelStore")
]
store_name = models.CharField(max_length=200)
user = models.ManyToManyField(User,
blank=True,
verbose_name="دسترسی مشاهده انبار احتمالا",
help_text="از این برای دسترسی مشاهده آن اتبار استفاده می شود"
)
owners = models.ManyToManyField(
User,
through='StoreRoomOwner', # کلید حل مشکل بدون شکستن کدهای قبلی
blank=True,
related_name="owned_storerooms",
verbose_name="مالکین انبار در فاکتور و موجودی و...",
help_text="این کاربران اگر فاکتور بزنند از این انبار کسر می‌گردد"
)
def __str__(self):
return self.store_name
class StoreRoomOwner(models.Model):
"""
این جدول واسط، پشت صحنه قرار می‌گیرد تا فقط قانون مالکیت را مدیریت کند.
کدهای دیگر پروژه اصلاً نیازی ندارند مستقیم با این جدول درگیر شوند.
"""
storeroom = models.ForeignKey(StoreRoom, on_delete=models.CASCADE)
# OneToOneField تضمین می‌کند یک کاربر نمی‌تواند در این جدول (و هیچ انبار دیگری) تکرار شود
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
error_messages={
'unique': 'این کاربر در حال حاضر مالک انبار دیگری است.'
}
)
class ProductCategory(models.Model):
category_name = models.CharField(max_length=50)
def __str__(self):
return self.category_name
class Product(models.Model):
class Meta:
permissions = [
("ViewProduct", "ViewProduct"),
]
ProductCategory = models.ForeignKey(ProductCategory, on_delete=models.CASCADE)
product_name = models.CharField(max_length=50)
product_price = models.IntegerField()
year = models.IntegerField(default=1400)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
def __str__(self):
return self.product_name
class Factor(models.Model):
class Meta:
permissions = [
("ViewFactor", "ViewFactor"),
("DelFactor", "DelFactor"),
("EditFactor", "EditFactor"),
("AddFactor", "AddFactor")
]
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
create_date = models.DateTimeField(auto_now_add=True)
amount = models.IntegerField()
discount = models.IntegerField(null=True, blank=True)
deadline = models.DateTimeField(null=True, blank=True)
desc = models.CharField(max_length=100, default='')
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
statusList = [
('unpaid', 'unpaid'),
('paid', 'paid'),
('cancell', 'cancell'),
]
pay_status = models.CharField(max_length=10, choices=statusList, default='')
FactorTypeList = [
('Nasb', 'Nasb'),
('Khadamat', 'Khadamat'),
('Garranty', 'Garranty'),
]
FactorType = models.CharField(max_length=10, choices=FactorTypeList, default='')
PayTypeList = [
('naghd', 'naghd'),
('cardToCard', 'cardToCard'),
('kartKhan', 'kartKhan'),
('nesiye', 'nesiye'),
('check', 'check'),
]
PayType = models.CharField(max_length=30, choices=PayTypeList, default='')
# فیلد جدید برای حذف نرم
is_active = models.BooleanField(default=True, null=True, blank=True)
# بازنویسی متد حذف: ابطال فاکتور به جای حذف فیزیکی
def delete(self, *args, **kwargs):
# فاکتور باطل می‌شود اما رکوردهای انبار دست‌نخورده باقی می‌مانند!
self.is_active = False
self.pay_status = 'cancell'
self.save()
class ProductInStore(models.Model):
class Meta:
permissions = [
("ViewProductInStore", "ViewProductInStore"),
("EditProductInStore", "EditProductInStore"),
("OnlyAnbar", "OnlyAnbar"),
]
class MovementType(models.TextChoices):
SALE = 'SALE', 'فروش عادی'
WARRANTY = 'WARRANTY', 'گارانتی'
PROMOTION = 'PROMOTION', 'اشانتیون'
TRANSFER = 'TRANSFER', 'انتقال'
INITIAL = 'INITIAL', 'موجودی اولیه'
DIRECT_IN = 'DIRECT_IN', 'ورود دستی'
DIRECT_OUT = 'DIRECT_OUT', 'خروج دستی'
RETURN = 'RETURN', 'برگشت از فروش'
ERROR_CORRECTION = 'ERROR_CORRECTION', 'اصلاح خطای ثبت'
StoreRoom = models.ForeignKey(StoreRoom, on_delete=models.CASCADE)
Product = models.ForeignKey(Product, on_delete=models.CASCADE)
s_in = models.IntegerField(default=0)
s_out = models.IntegerField(default=0)
current_stock_at_time = models.IntegerField(default=0, help_text="موجودی انبار در لحظه این تراکنش")
desc = models.CharField(max_length=50, default='')
year = models.IntegerField(default=1400)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
# تغییر بسیار مهم استراتژیک: تبدیل CASCADE به PROTECT
# هیچکس نمی‌تواند فاکتوری که کالا از انبار خارج کرده را از دیتابیس پاک کند!
factor = models.ForeignKey(Factor, on_delete=models.PROTECT, null=True, blank=True)
movement_type = models.CharField(
max_length=20,
choices=MovementType.choices,
db_index=True,
null=True,
blank=True
)
def __str__(self):
return f"{self.Product.product_name} - In: {self.s_in} Out: {self.s_out}"
# متدهای save و delete دقیقاً همان کدهای بی‌نقص و پرسرعتی هستند که در پیام قبل تایید کردیم
def save(self, *args, **kwargs):
try:
self.s_in = int(self.s_in or 0)
self.s_out = int(self.s_out or 0)
except (ValueError, TypeError):
self.s_in, self.s_out = 0, 0
is_created = self.pk is None
with transaction.atomic():
qs_lock = ProductInStore.objects.filter(StoreRoom=self.StoreRoom, Product=self.Product)
if connection.features.has_select_for_update:
qs_lock = qs_lock.select_for_update()
list(qs_lock)
if not is_created:
old_instance = ProductInStore.objects.get(pk=self.pk)
old_balance = old_instance.s_in - old_instance.s_out
new_balance = self.s_in - self.s_out
change_amount = new_balance - old_balance
description = self.desc or f"ویرایش تراکنش: {self.desc}"
else:
change_amount = self.s_in - self.s_out
description = self.desc or "ثبت تراکنش جدید انبار"
super().save(*args, **kwargs)
totals = ProductInStore.objects.filter(StoreRoom=self.StoreRoom, Product=self.Product).aggregate(
total_in=Sum('s_in'),
total_out=Sum('s_out')
)
total_in = totals['total_in'] or 0
total_out = totals['total_out'] or 0
current_stock = total_in - total_out
ProductInStore.objects.filter(pk=self.pk).update(current_stock_at_time=current_stock)
self.current_stock_at_time = current_stock
ProductStockHistory.objects.create(
store_room=self.StoreRoom,
product=self.Product,
quantity_changed=change_amount,
current_stock=current_stock,
desc=description
)
def delete(self, *args, **kwargs):
store_room = self.StoreRoom
product = self.Product
change_amount = -(int(self.s_in or 0) - int(self.s_out or 0))
description = f"حذف دستی تراکنش انبار: {self.desc}"
with transaction.atomic():
qs_lock = ProductInStore.objects.filter(StoreRoom=store_room, Product=product)
if connection.features.has_select_for_update:
qs_lock = qs_lock.select_for_update()
list(qs_lock)
super().delete(*args, **kwargs)
totals = ProductInStore.objects.filter(StoreRoom=store_room, Product=product).aggregate(
total_in=Sum('s_in'),
total_out=Sum('s_out')
)
current_stock = (totals['total_in'] or 0) - (totals['total_out'] or 0)
ProductStockHistory.objects.create(
store_room=store_room,
product=product,
quantity_changed=change_amount,
current_stock=current_stock,
desc=description
)
class ProductStockHistory(models.Model):
store_room = models.ForeignKey(StoreRoom, on_delete=models.CASCADE, related_name='stock_histories')
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='stock_histories')
quantity_changed = models.IntegerField(help_text="مقدار تغییر یافته (مثبت برای ورود، منفی برای خروج)")
current_stock = models.IntegerField(help_text="موجودی کل انبار در این لحظه")
created_at = models.DateTimeField(auto_now_add=True)
desc = models.CharField(max_length=255, blank=True, null=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.product.product_name} in {self.store_room.store_name} - Stock: {self.current_stock}"
class StoreTransferRequest(models.Model):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار تایید انبار مقصد'
APPROVED = 'APPROVED', 'تایید شده و منتقل شده'
REJECTED = 'REJECTED', 'رد شده توسط انبار مقصد'
CANCELED = 'CANCELED', 'انصراف توسط مبدا'
source_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='outgoing_transfers',
verbose_name="انبار مبدا"
)
target_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='incoming_transfers',
verbose_name="انبار مقصد"
)
product = models.ForeignKey(
'Product',
on_delete=models.PROTECT,
verbose_name="کالا"
)
quantity = models.PositiveIntegerField(verbose_name="تعداد انتقال")
status = models.CharField(
max_length=15,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
verbose_name="وضعیت"
)
desc = models.CharField(max_length=500, blank=True, null=True, verbose_name="توضیحات درخواست")
rejection_reason = models.CharField(max_length=500, blank=True, null=True, verbose_name="دلیل رد درخواست")
requester = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='requested_transfers',
verbose_name="کاربر درخواست‌دهنده"
)
approver = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='processed_transfers',
verbose_name="کاربر تعیین‌تکلیف‌کننده"
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="زمان ثبت درخواست")
updated_at = models.DateTimeField(auto_now=True, verbose_name="زمان آخرین به‌روزرسانی")
action_date = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="زمان تعیین تکلیف")
out_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_out_ref',
verbose_name="سند خروج انبار مبدا"
)
in_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_in_ref',
verbose_name="سند ورود انبار مقصد"
)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"[{self.get_status_display()}] {self.quantity} عدد {self.product.product_name} از {self.source_store.store_name} به {self.target_store.store_name}"
def clean(self):
super().clean()
if self.source_store_id == self.target_store_id:
raise ValidationError("انبار مبدا و مقصد نمی‌توانند یکسان باشند.")
if self.quantity and self.quantity <= 0:
raise ValidationError("تعداد انتقال باید بیشتر از صفر باشد.")
class FactorIteam(models.Model): # پیشنهاد: در آینده املای نام این کلاس را به FactorItem اصلاح کنید
factor = models.ForeignKey(Factor, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True)
product_name = models.CharField(max_length=200, default='')
num = models.IntegerField()
amount = models.IntegerField()
garantyEsh = models.CharField(max_length=100, default='', null=True, blank=True)
class VisitQueue(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
dateTime = models.DateTimeField()
statusList = [
('waiting', 'waiting'),
('finish', 'finish'),
('cancell', 'cancell'),
]
status = models.CharField(max_length=10, choices=statusList, default='waiting')
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
GradeList = [
('3', '3'),
('2', '2'),
('1', '1'),
]
grade = models.CharField(max_length=1, choices=GradeList)
grade_desc = models.CharField(max_length=200)
class ArticleType(models.Model):
name = models.CharField(max_length=50)
class Article(models.Model):
class Meta:
permissions = [
("ViewReportMali", "ViewReportMali")
]
debit = models.IntegerField(null=True, blank=True)
credit = models.IntegerField(null=True, blank=True)
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, null=True, blank=True)
factor = models.ForeignKey(Factor, on_delete=models.CASCADE, null=True, blank=True)
type = models.CharField(max_length=50, null=True, blank=True)
typePy = models.CharField(max_length=50, null=True, blank=True)
checkNum = models.IntegerField(null=True, blank=True)
nahvePardakht = models.CharField(max_length=50, null=True, blank=True)
sarresid = models.DateTimeField(null=True, blank=True)
payStatus = models.CharField(max_length=50, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
desc = models.CharField(max_length=200)
user = models.ForeignKey(userModel, on_delete=models.SET_NULL, null=True, blank=True)
class Action(models.Model):
actionType = models.CharField(max_length=50, null=True, blank=True)
forUser = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
customer = models.ForeignKey(Customer, related_name='actionrlt', on_delete=models.CASCADE, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
actionDateTime = models.DateTimeField(null=True, blank=True)
finishDateTime = models.DateTimeField(null=True, blank=True)
desc = models.CharField(max_length=500, null=True, blank=True)
result = models.CharField(max_length=500, null=True, blank=True)
status = models.CharField(max_length=50, null=True, blank=True)
nazar = models.CharField(max_length=50, null=True, blank=True, default=None)
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
class Sarresid(models.Model):
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
price = models.IntegerField(null=False, blank=False)
payStatus = models.CharField(max_length=50, null=True, blank=True)
type = models.CharField(max_length=50, null=True, blank=True)
factor = models.ForeignKey('Factor', on_delete=models.CASCADE, null=True, blank=True)
sarresidDT = models.DateTimeField(null=True, blank=True)
user = models.ForeignKey(userModel, on_delete=models.SET_NULL, null=True, blank=True)
desc = models.TextField(null=True, blank=True)
# ---------------- فیلدهای استاندارد و ایمن‌شده چک ----------------
check_row_number = models.CharField(
max_length=50,
null=True, blank=True,
verbose_name="شماره ردیف چک"
)
check_sayyad_id = models.CharField(
max_length=16,
validators=[
RegexValidator(regex=r'^\d{16}$', message='شناسه صیادی باید دقیقاً ۱۶ رقم عدد باشد.')
],
null=True, blank=True,
verbose_name="شناسه صیادی"
)
check_owner_name = models.CharField(
max_length=100,
null=True, blank=True,
verbose_name="نام مالک چک"
)
check_owner_phone = models.CharField(
max_length=11,
validators=[
RegexValidator(regex=r'^\d{11}$', message='شماره تلفن باید ۱۱ رقم و فقط شامل اعداد باشد.')
],
null=True, blank=True,
verbose_name="تلفن مالک چک"
)
class CacheTable(models.Model):
key = models.CharField(max_length=50, unique=True)
value = models.CharField(max_length=400)
def __str__(self):
return f'{self.key} # {self.value}'
+20
View File
@@ -0,0 +1,20 @@
"""
پکیج مدل‌های admin_panel.
این پکیج جایگزین فایل تک‌پارچهٔ models.py است و آن را به ماژول‌های کوچک‌تر
دامنه‌ای تقسیم می‌کند. همهٔ نام‌ها اینجا re-export می‌شوند تا تمام
مصرف‌کننده‌های فعلی (`from .models import *`, `from admin_panel.models import X`)
بدون هیچ تغییری کار کنند و app_label و مایگریشن‌ها ثابت بمانند.
نکته: عمداً __all__ تعریف نشده تا رفتار star-import دقیقاً مانند فایل قبلی باشد
(نام‌هایی مثل `models`, `User`, `Sum` نیز مثل قبل منتشر می‌شوند).
"""
from .users import * # noqa: F401,F403
from .customers import * # noqa: F401,F403
from .products import * # noqa: F401,F403
from .store import * # noqa: F401,F403
from .factors import * # noqa: F401,F403
from .finance import * # noqa: F401,F403
from .actions import * # noqa: F401,F403
from .system import * # noqa: F401,F403
from .sms import * # noqa: F401,F403
+103
View File
@@ -0,0 +1,103 @@
from django.db import models
from django.contrib.auth.models import User
class VisitQueue(models.Model):
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
dateTime = models.DateTimeField()
statusList = [
('waiting', 'waiting'),
('finish', 'finish'),
('cancell', 'cancell'),
]
status = models.CharField(max_length=10, choices=statusList, default='waiting')
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
GradeList = [
('3', '3'),
('2', '2'),
('1', '1'),
]
grade = models.CharField(max_length=1, choices=GradeList)
grade_desc = models.CharField(max_length=200)
class Action(models.Model):
"""اقدام/وظیفهٔ CRM مرتبط با مشتری.
``actionType`` فقط این شش مقدار را می‌پذیرد؛ همین شش مقدار در فرم‌های پنل
(``customerActionInc.html``) و در تمام مقایسه‌های کد (``== 'bazdid'``،
``exclude(actionType='vosol')`` و …) استفاده می‌شوند.
``status`` نیز همان سه مقدارِ به‌کاررفته در کد و قالب‌ها را می‌پذیرد.
املای ``'waite'`` عمداً حفظ شده است: ۲٫۷۱۹ رکورد موجودِ دیتابیس و تمام
مقایسه‌های کد/قالب (``== 'waite'``، ``if filter_status == 'waite'``) با
همین املا کار می‌کنند و تغییرش داده را از موتور جست‌وجو جدا می‌کند.
"""
class ActionType(models.TextChoices):
BAZDID = 'bazdid', 'بازدید'
NASB = 'nasb', 'نصب'
VOSOL = 'vosol', 'وصول مطالبات'
HAMAHANGI = 'hamahangi', 'هماهنگی'
IN_CALL = 'inCall', 'تماس ورودی'
OUT_CALL = 'outCall', 'تماس خروجی'
class Status(models.TextChoices):
# املا عیناً مطابق داده و کد فعلی است (نه 'wait').
WAITE = 'waite', 'در انتظار (باز)'
FINISH = 'finish', 'انجام شده'
CANCELL = 'cancell', 'لغو شده'
actionType = models.CharField(
max_length=50, null=True, blank=True, choices=ActionType.choices,
)
forUser = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
customer = models.ForeignKey('Customer', related_name='actionrlt', on_delete=models.CASCADE, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
actionDateTime = models.DateTimeField(null=True, blank=True)
finishDateTime = models.DateTimeField(null=True, blank=True)
desc = models.CharField(max_length=500, null=True, blank=True)
result = models.CharField(max_length=500, null=True, blank=True)
status = models.CharField(
max_length=50, null=True, blank=True, choices=Status.choices,
)
nazar = models.CharField(max_length=50, null=True, blank=True, default=None)
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
# --- حسابرسی «حذف» (soft-delete از طریق status='cancell') ---
# چرا ستون جدا؟ چون ``cancell`` از قبل معنای «لغو برنامه» را دارد و در گزارش‌ها
# هم استفاده می‌شود. با این دو ستون می‌توانیم «حذف توسط کاربر» را از «لغو
# عادی» تفکیک کنیم، بدون آنکه منطق محاسبات (که فقط waite/finish را می‌بیند)
# دست بخورد.
deleted_at = models.DateTimeField(
null=True, blank=True, default=None, verbose_name="زمان حذف",
help_text="اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).",
)
deleted_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name="deleted_actions", verbose_name="حذف‌کننده",
)
@property
def is_deleted(self):
"""آیا این اقدام با «حذف» کنار گذاشته شده است؟ (نه لغو عادی)"""
return self.deleted_at is not None
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
# نمی‌کند. اندازه‌گیری روی دادهٔ واقعی: ۱۶۴ms → ۲۹ms.
# ستون‌های actionType/status/customer_id برای فیلتر و group، و دو ستون
# تاریخ برای محاسبهٔ «تاریخ مؤثر» در همان ایندکس.
indexes = [
models.Index(
fields=[
'actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime',
],
name='act_visit_idx',
),
]
+79
View File
@@ -0,0 +1,79 @@
from django.db import models
class Customer(models.Model):
class Meta:
permissions = [
("AddCustomer", "AddCustomer"),
("DelCustomer", "DelCustomer"),
("EditCustomer", "EditCustomer"),
("ViewCustomer", "ViewCustomer")
]
name_family = models.CharField(max_length=200)
address = models.CharField(max_length=200)
# توضیحات آزاد مشتری؛ متن‌های غیرشماره‌ای که از فیلدهای تلفن پاک‌سازی می‌شوند
# اینجا نگه داشته می‌شوند تا هیچ داده‌ای از سیستم حذف نشود.
notes = models.TextField(
blank=True, default="", verbose_name="توضیحات",
help_text="یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.",
)
phone1 = models.CharField(max_length=20, default='')
phone2 = models.CharField(max_length=20, default='', null=True, blank=True)
phone3 = models.CharField(max_length=20, default='', null=True, blank=True)
idCode = models.CharField(max_length=20, default='', null=True, blank=True)
installDate = models.DateField(auto_now_add=True)
create_date = models.DateTimeField(auto_now_add=True)
last_bazdid_dt = models.DateTimeField(null=True, blank=True)
laghv_bazdid = models.BooleanField(null=True, blank=True, default=False)
# فیلد جدید برای حذف نرم (بدون تداخل با داده‌های قبلی)
is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری")
# شماره‌های «اصلی» گیرندهٔ پیامک، به‌صورت مقدارِ خودِ شماره (نرمال‌شده، جدا با کاما).
# چرا مقدار و نه کلیدِ رکورد؟ چون دادهٔ مشتریان دائماً افزوده/ویرایش/حذف می‌شود و
# کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره پایدار است. اگر شماره‌ای حذف یا
# ویرایش شود، این انتخاب خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول برمی‌گردد.
sms_primary_phones = models.CharField(
max_length=100, blank=True, default="",
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
)
# ------------------------------------------------------------------
# «قرار بعدی» — تعویقِ دستیِ بازدید (تصمیم محصولی)
# ------------------------------------------------------------------
# این ستون **اعتبار مستقل ندارد**: فقط وقتی اثر دارد که یک اقدام «هماهنگی»
# فعال (``waite``/``finish``) با تاریخ آینده برای همان مشتری وجود داشته باشد.
# منبع حقیقت همچنان جدول ``Action`` است و این مقدار یک میان‌بر سریع برای
# نمایش/فیلتر است. اگر هماهنگی حذف/لغو شود، این مقدار خودبه‌خود بی‌اثر می‌شود
# (نگاه کنید به ``visit_policy.manual_holds_map``).
visit_hold_until = models.DateTimeField(
null=True, blank=True,
verbose_name="قرار بعدی (تعویق بازدید)",
help_text="تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم می‌شود.",
)
visit_hold_set_at = models.DateTimeField(
null=True, blank=True,
verbose_name="زمان ثبت قرار",
help_text="برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.",
)
def __str__(self):
return self.name_family
# بازنویسی متد حذف: جلوگیری از فاجعه در دیتابیس
def delete(self, *args, **kwargs):
# به جای حذف فیزیکی که انبار و فاکتورها را نابود می‌کند، مشتری را غیرفعال می‌کنیم
self.is_active = False
self.save()
class PhoneNumber(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones')
title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)")
phone_number = models.CharField(max_length=20)
# توجه: «شمارهٔ اصلی پیامک» روی خودِ Customer.sms_primary_phones نگه داشته می‌شود
# (کلیدش مقدار شماره است، نه رکورد) تا با افزودن/حذف/ویرایش مداوم مشتریان از بین نرود.
def __str__(self):
return f"{self.title}: {self.phone_number}"
+58
View File
@@ -0,0 +1,58 @@
from django.db import models
from django.contrib.auth.models import User
class Factor(models.Model):
class Meta:
permissions = [
("ViewFactor", "ViewFactor"),
("DelFactor", "DelFactor"),
("EditFactor", "EditFactor"),
("AddFactor", "AddFactor")
]
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
create_date = models.DateTimeField(auto_now_add=True)
amount = models.IntegerField()
discount = models.IntegerField(null=True, blank=True)
deadline = models.DateTimeField(null=True, blank=True)
desc = models.CharField(max_length=100, default='')
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
statusList = [
('unpaid', 'unpaid'),
('paid', 'paid'),
('cancell', 'cancell'),
]
pay_status = models.CharField(max_length=10, choices=statusList, default='')
FactorTypeList = [
('Nasb', 'Nasb'),
('Khadamat', 'Khadamat'),
('Garranty', 'Garranty'),
]
FactorType = models.CharField(max_length=10, choices=FactorTypeList, default='')
PayTypeList = [
('naghd', 'naghd'),
('cardToCard', 'cardToCard'),
('kartKhan', 'kartKhan'),
('nesiye', 'nesiye'),
('check', 'check'),
]
PayType = models.CharField(max_length=30, choices=PayTypeList, default='')
# فیلد جدید برای حذف نرم
is_active = models.BooleanField(default=True, null=True, blank=True)
# بازنویسی متد حذف: ابطال فاکتور به جای حذف فیزیکی
def delete(self, *args, **kwargs):
# فاکتور باطل می‌شود اما رکوردهای انبار دست‌نخورده باقی می‌مانند!
self.is_active = False
self.pay_status = 'cancell'
self.save()
class FactorIteam(models.Model): # پیشنهاد: در آینده املای نام این کلاس را به FactorItem اصلاح کنید
factor = models.ForeignKey(Factor, on_delete=models.CASCADE)
product = models.ForeignKey('Product', on_delete=models.CASCADE, null=True, blank=True)
product_name = models.CharField(max_length=200, default='')
num = models.IntegerField()
amount = models.IntegerField()
garantyEsh = models.CharField(max_length=100, default='', null=True, blank=True)
+71
View File
@@ -0,0 +1,71 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.core.validators import RegexValidator
userModel = get_user_model()
class ArticleType(models.Model):
name = models.CharField(max_length=50)
class Article(models.Model):
class Meta:
permissions = [
("ViewReportMali", "ViewReportMali")
]
debit = models.IntegerField(null=True, blank=True)
credit = models.IntegerField(null=True, blank=True)
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, null=True, blank=True)
factor = models.ForeignKey('Factor', on_delete=models.CASCADE, null=True, blank=True)
type = models.CharField(max_length=50, null=True, blank=True)
typePy = models.CharField(max_length=50, null=True, blank=True)
checkNum = models.IntegerField(null=True, blank=True)
nahvePardakht = models.CharField(max_length=50, null=True, blank=True)
sarresid = models.DateTimeField(null=True, blank=True)
payStatus = models.CharField(max_length=50, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
desc = models.CharField(max_length=200)
user = models.ForeignKey(userModel, on_delete=models.SET_NULL, null=True, blank=True)
class Sarresid(models.Model):
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
price = models.IntegerField(null=False, blank=False)
payStatus = models.CharField(max_length=50, null=True, blank=True)
type = models.CharField(max_length=50, null=True, blank=True)
factor = models.ForeignKey('Factor', on_delete=models.CASCADE, null=True, blank=True)
sarresidDT = models.DateTimeField(null=True, blank=True)
user = models.ForeignKey(userModel, on_delete=models.SET_NULL, null=True, blank=True)
desc = models.TextField(null=True, blank=True)
# ---------------- فیلدهای استاندارد و ایمن‌شده چک ----------------
check_row_number = models.CharField(
max_length=50,
null=True, blank=True,
verbose_name="شماره ردیف چک"
)
check_sayyad_id = models.CharField(
max_length=16,
validators=[
RegexValidator(regex=r'^\d{16}$', message='شناسه صیادی باید دقیقاً ۱۶ رقم عدد باشد.')
],
null=True, blank=True,
verbose_name="شناسه صیادی"
)
check_owner_name = models.CharField(
max_length=100,
null=True, blank=True,
verbose_name="نام مالک چک"
)
check_owner_phone = models.CharField(
max_length=11,
validators=[
RegexValidator(regex=r'^\d{11}$', message='شماره تلفن باید ۱۱ رقم و فقط شامل اعداد باشد.')
],
null=True, blank=True,
verbose_name="تلفن مالک چک"
)
+148
View File
@@ -0,0 +1,148 @@
from django.db import models, transaction, connection
from django.db.models import Sum
class ProductCategory(models.Model):
category_name = models.CharField(max_length=50)
def __str__(self):
return self.category_name
class Product(models.Model):
class Meta:
permissions = [
("ViewProduct", "ViewProduct"),
]
ProductCategory = models.ForeignKey(ProductCategory, on_delete=models.CASCADE)
product_name = models.CharField(max_length=50)
product_price = models.IntegerField()
year = models.IntegerField(default=1400)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
def __str__(self):
return self.product_name
class ProductInStore(models.Model):
class Meta:
permissions = [
("ViewProductInStore", "ViewProductInStore"),
("EditProductInStore", "EditProductInStore"),
("OnlyAnbar", "OnlyAnbar"),
]
class MovementType(models.TextChoices):
SALE = 'SALE', 'فروش عادی'
WARRANTY = 'WARRANTY', 'گارانتی'
PROMOTION = 'PROMOTION', 'اشانتیون'
TRANSFER = 'TRANSFER', 'انتقال'
INITIAL = 'INITIAL', 'موجودی اولیه'
DIRECT_IN = 'DIRECT_IN', 'ورود دستی'
DIRECT_OUT = 'DIRECT_OUT', 'خروج دستی'
RETURN = 'RETURN', 'برگشت از فروش'
ERROR_CORRECTION = 'ERROR_CORRECTION', 'اصلاح خطای ثبت'
StoreRoom = models.ForeignKey('StoreRoom', on_delete=models.CASCADE)
Product = models.ForeignKey(Product, on_delete=models.CASCADE)
s_in = models.IntegerField(default=0)
s_out = models.IntegerField(default=0)
current_stock_at_time = models.IntegerField(default=0, help_text="موجودی انبار در لحظه این تراکنش")
desc = models.CharField(max_length=50, default='')
year = models.IntegerField(default=1400)
createdDT = models.DateTimeField(auto_now_add=True)
changeDT = models.DateTimeField(auto_now=True)
# تغییر بسیار مهم استراتژیک: تبدیل CASCADE به PROTECT
# هیچکس نمی‌تواند فاکتوری که کالا از انبار خارج کرده را از دیتابیس پاک کند!
factor = models.ForeignKey('Factor', on_delete=models.PROTECT, null=True, blank=True)
movement_type = models.CharField(
max_length=20,
choices=MovementType.choices,
db_index=True,
null=True,
blank=True
)
def __str__(self):
return f"{self.Product.product_name} - In: {self.s_in} Out: {self.s_out}"
# متدهای save و delete دقیقاً همان کدهای بی‌نقص و پرسرعتی هستند که در پیام قبل تایید کردیم
def save(self, *args, **kwargs):
from .store import ProductStockHistory
try:
self.s_in = int(self.s_in or 0)
self.s_out = int(self.s_out or 0)
except (ValueError, TypeError):
self.s_in, self.s_out = 0, 0
is_created = self.pk is None
with transaction.atomic():
qs_lock = ProductInStore.objects.filter(StoreRoom=self.StoreRoom, Product=self.Product)
if connection.features.has_select_for_update:
qs_lock = qs_lock.select_for_update()
list(qs_lock)
if not is_created:
old_instance = ProductInStore.objects.get(pk=self.pk)
old_balance = old_instance.s_in - old_instance.s_out
new_balance = self.s_in - self.s_out
change_amount = new_balance - old_balance
description = self.desc or f"ویرایش تراکنش: {self.desc}"
else:
change_amount = self.s_in - self.s_out
description = self.desc or "ثبت تراکنش جدید انبار"
super().save(*args, **kwargs)
totals = ProductInStore.objects.filter(StoreRoom=self.StoreRoom, Product=self.Product).aggregate(
total_in=Sum('s_in'),
total_out=Sum('s_out')
)
total_in = totals['total_in'] or 0
total_out = totals['total_out'] or 0
current_stock = total_in - total_out
ProductInStore.objects.filter(pk=self.pk).update(current_stock_at_time=current_stock)
self.current_stock_at_time = current_stock
ProductStockHistory.objects.create(
store_room=self.StoreRoom,
product=self.Product,
quantity_changed=change_amount,
current_stock=current_stock,
desc=description
)
def delete(self, *args, **kwargs):
from .store import ProductStockHistory
store_room = self.StoreRoom
product = self.Product
change_amount = -(int(self.s_in or 0) - int(self.s_out or 0))
description = f"حذف دستی تراکنش انبار: {self.desc}"
with transaction.atomic():
qs_lock = ProductInStore.objects.filter(StoreRoom=store_room, Product=product)
if connection.features.has_select_for_update:
qs_lock = qs_lock.select_for_update()
list(qs_lock)
super().delete(*args, **kwargs)
totals = ProductInStore.objects.filter(StoreRoom=store_room, Product=product).aggregate(
total_in=Sum('s_in'),
total_out=Sum('s_out')
)
current_stock = (totals['total_in'] or 0) - (totals['total_out'] or 0)
ProductStockHistory.objects.create(
store_room=store_room,
product=product,
quantity_changed=change_amount,
current_stock=current_stock,
desc=description
)
+326
View File
@@ -0,0 +1,326 @@
"""مدل‌های زیرسیستم پیامک.
شامل:
- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin.
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری.
- صف ارسال (outbox) و لاگ تلاش‌های ارسال.
"""
from django.conf import settings
from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
اسکنر دوره‌ای در صف قرار می‌گیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
و ``admin_panel/sms/scanner.py`` است.
"""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
SURVEY = "survey", "نظرسنجی پس از ثبت فاکتور"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
class SMSProviderConfig(models.Model):
"""تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin."""
class Meta:
verbose_name = "تنظیمات سامانه پیامک"
verbose_name_plural = "تنظیمات سامانه پیامک"
permissions = [
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
# سطح دسترسی جداگانه برای صفحات «تنظیمات» و «قالب‌ها»: مدیر می‌تواند فقط
# به کاربران مشخصی اجازهٔ دیدن/ویرایش این‌ها را بدهد (نه هر کاربر پیامک).
("ManageSMSSettings", "ManageSMSSettings"),
]
ProviderChoices = [
("sms0098", "0098SMS (سرویس لینکی HTTP)"),
("sms0098ws", "0098SMS (وب‌سرویس + استعلام دلیوری)"),
("http", "HTTP عمومی"),
("console", "Console (تست/دیباگ)"),
]
provider = models.CharField(
max_length=20, choices=ProviderChoices, default="sms0098",
verbose_name="سرویس‌دهنده پیامک",
)
is_enabled = models.BooleanField(
default=True, verbose_name="فعال بودن ارسال پیامک",
help_text="در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی‌کند.",
)
api_url = models.CharField(
max_length=500, blank=True,
default="http://www.0098sms.com/sendsmslink.aspx",
verbose_name="آدرس سرویس پیامک (URL)",
help_text="برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.",
)
username = models.CharField(max_length=100, blank=True, default="", verbose_name="نام کاربری")
password = models.CharField(max_length=200, blank=True, default="", verbose_name="رمز عبور")
sender = models.CharField(max_length=50, blank=True, default="", verbose_name="شماره خط فرستنده")
webservice_url = models.CharField(
max_length=500, blank=True,
default="https://webservice.0098sms.com/service.asmx",
verbose_name="آدرس وب‌سرویس (SOAP/WSDL)",
help_text="فقط برای provider «وب‌سرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام "
"دلیوری از همین آدرس انجام می‌شود.",
)
panel_no = models.CharField(
max_length=50, blank=True, default="",
verbose_name="شماره پنل (PnlNo)",
help_text="شمارهٔ اختصاصی پنل در وب‌سرویس؛ اگر خالی بماند از «شماره خط فرستنده» "
"استفاده می‌شود.",
)
api_token = models.CharField(max_length=255, blank=True, default="", verbose_name="توکن API (HTTP)")
test_message = models.TextField(
blank=True,
default="پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.",
verbose_name="متن پیامک آزمایشی",
help_text="متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.",
)
timeout_seconds = models.PositiveIntegerField(default=30, verbose_name="مهلت ارتباط (ثانیه)")
# --- کنترل جریان ارسال (جلوگیری از سیل پیامک) ---
scan_since = models.DateTimeField(
null=True, blank=True,
verbose_name="تاریخ شروع دوره",
help_text="فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز "
"می‌شود در صف قرار می‌گیرند؛ موارد قدیمی‌تر (مثل مشتریان خیلی قدیمی که "
"مدت‌هاست بازدید نشده‌اند) نادیده گرفته می‌شوند. از پنل به شکل تاریخ شمسی "
"ویرایش می‌شود و روی «آغاز همان روز» تنظیم می‌گردد. اگر خالی باشد، در "
"نخستین اجرا خودکار روی «همین حالا» تنظیم می‌شود.",
)
allow_backlog = models.BooleanField(
default=False,
verbose_name="اجازهٔ ارسال موارد قدیمی",
help_text="اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده‌اند هم "
"(به‌اندازهٔ سقف زیر، در هر اجرا) ارسال می‌شوند. به‌صورت پیش‌فرض خاموش است "
"تا مشتریان قدیمی مشمول این فیلتر شوند.",
)
backlog_max = models.PositiveIntegerField(
default=0, verbose_name="حداکثر موارد قدیمی در هر اجرا",
help_text="فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.",
)
max_per_run = models.PositiveIntegerField(
default=200, verbose_name="حداکثر پیامک هر اسکنر در هر اجرا",
help_text="سقف کلی صف‌کردن برای هر نوع پیامک در هر اجرای اسکنر.",
)
send_interval_seconds = models.PositiveIntegerField(
default=3, verbose_name="فاصلهٔ بین هر پیامک (ثانیه)",
help_text="worker پس از هر ارسال این مدت صبر می‌کند تا سرویس‌دهنده زیر بار نرود.",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"تنظیمات پیامک ({self.get_provider_display()})"
def save(self, *args, **kwargs):
# سینگلتون: همیشه فقط یک رکورد با pk=1.
self.pk = 1
# نخستین بارگذاری = لحظهٔ فعال‌سازی؛ خط شروع روی «همین حالا» مهر می‌شود تا
# موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نگیرند.
if self.scan_since is None:
from django.utils import timezone
self.scan_since = timezone.now()
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
if obj.scan_since is None:
obj.save(update_fields=["scan_since"])
return obj
class SMSTemplate(models.Model):
"""قالب متن هر نوع پیامک. متن قابل ویرایش از پنل است."""
class Meta:
verbose_name = "قالب پیامک"
verbose_name_plural = "قالب‌های پیامک"
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, unique=True, verbose_name="نوع پیامک",
)
title = models.CharField(max_length=100, verbose_name="عنوان")
text = models.TextField(
verbose_name="متن پیامک",
help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {sayyad_id} شناسه صیاد چک، "
"{owner_name} نام مالک چک، {balance} مانده حساب مشتری، "
"{balance_sep} مانده حساب با جداکننده. "
"(توجه: {balance} مانده کل حساب است و با مبلغ سررسیدشدهٔ چک/نسیه "
"که در {amount} می‌آید تفاوت دارد.)",
)
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
auto_send = models.BooleanField(
default=False,
verbose_name="ارسال خودکار (بدون تأیید مدیر)",
help_text="اگر خاموش باشد (پیش‌فرض)، پیامک‌های این نوع در وضعیت «در انتظار تأیید» "
"در صف می‌مانند تا مدیر آن‌ها را تأیید کند. با روشن‌کردن، پیامکِ ساخته‌شده "
"توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می‌شود.",
)
offset_days = models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده می‌شود؛ "
"مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
)
repeat_interval_days = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="فاصله تکرار (روز)",
help_text="خالی بگذارید برای ارسال یک‌باره؛ مقدار بدهید برای ارسال تکرارشونده.",
)
repeat_interval_months = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. خالی = پیش‌فرض سامانه (۹ ماه).",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class CustomerSMSSetting(models.Model):
"""تنظیم فعال/غیرفعال هر نوع پیامک برای یک مشتری (مدل opt-out).
نبودِ رکورد برای یک (مشتری، نوع) یعنی فعال. تنها تغییرهای صریح ذخیره می‌شوند.
"""
class Meta:
verbose_name = "تنظیم پیامک مشتری"
verbose_name_plural = "تنظیمات پیامک مشتریان"
unique_together = ("customer", "sms_type")
customer = models.ForeignKey(
"Customer", on_delete=models.CASCADE, related_name="sms_settings",
)
sms_type = models.CharField(max_length=40, choices=SMSType.choices, verbose_name="نوع پیامک")
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
def __str__(self):
state = "فعال" if self.is_enabled else "غیرفعال"
return f"{self.customer} - {self.get_sms_type_display()} ({state})"
class SMSQueue(models.Model):
"""صف دیتابیسی ارسال پیامک (outbox). توسط worker تخلیه می‌شود."""
class Status(models.TextChoices):
PENDING = "pending", "در انتظار تأیید" # در صف بازبینی مدیر
APPROVED = "approved", "تأییدشده" # تأیید مدیر؛ آمادهٔ ارسال توسط worker
PROCESSING = "processing", "در حال پردازش"
SENT = "sent", "ارسال شده"
FAILED = "failed", "ناموفق"
CANCELLED = "cancelled", "لغو شده"
class DeliveryStatus(models.TextChoices):
"""وضعیت تحویل واقعی به گوشی (از استعلام web service، فقط اگر smsid داشته باشیم)."""
PENDING = "pending", "در انتظار استعلام"
DELIVERED = "delivered", "رسیده به گوشی"
NOT_DELIVERED = "not_delivered", "نرسیده به گوشی"
EXPIRED = "expired", "منقضی‌شده"
UNKNOWN = "unknown", "نامشخص"
customer = models.ForeignKey(
"Customer", on_delete=models.SET_NULL, null=True, blank=True,
related_name="sms_queue", verbose_name="مشتری",
)
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, blank=True, default="", verbose_name="نوع",
)
phone_number = models.CharField(max_length=20, verbose_name="شماره گیرنده")
message = models.TextField(verbose_name="متن")
status = models.CharField(
max_length=12, choices=Status.choices, default=Status.PENDING,
db_index=True, verbose_name="وضعیت",
)
attempts = models.PositiveSmallIntegerField(default=0, verbose_name="تعداد تلاش")
max_attempts = models.PositiveSmallIntegerField(default=5, verbose_name="حداکثر تلاش")
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name="زمان تلاش بعدی")
last_error = models.TextField(blank=True, default="", verbose_name="آخرین خطا")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
dedup_key = models.CharField(
max_length=120, unique=True, null=True, blank=True,
help_text="کلید یکتاسازی برای جلوگیری از ارسال تکراری.",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent_at = models.DateTimeField(null=True, blank=True)
# --- رهگیری تحویل (فقط برای providerهایی که شناسهٔ پیامک برمی‌گردانند) ---
delivery_status = models.CharField(
max_length=16, choices=DeliveryStatus.choices, blank=True, default="",
db_index=True, verbose_name="وضعیت تحویل",
help_text="خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می‌شود.",
)
delivery_checked_at = models.DateTimeField(
null=True, blank=True, verbose_name="زمان آخرین استعلام دلیوری",
)
class Meta:
verbose_name = "صف پیامک"
verbose_name_plural = "صف پیامک‌ها"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "next_retry_at"]),
]
def __str__(self):
return f"{self.get_sms_type_display() or self.sms_type} → {self.phone_number} [{self.status}]"
class SMSDedup(models.Model):
"""دفتر کلیدهای یکتاسازیِ *همیشه‌ثبت‌شده*.
``SMSQueue.dedup_key`` تنها تا وقتی جلوی تکرار را می‌گیرد که رکورد صف باقی
باشد؛ با حذف رکورد از صف (رد کردن پیامک توسط مدیر) کلید هم پاک می‌شد و اسکنر
در اجرای بعدی همان پیامک را دوباره می‌ساخت. این دفتر مستقل از صف است و هرگز
حذف نمی‌شود، پس پیامکِ حذف‌شده دیگر بازنمی‌گردد.
"""
class Meta:
verbose_name = "کلید پردازش‌شدهٔ پیامک"
verbose_name_plural = "کلیدهای پردازش‌شدهٔ پیامک"
key = models.CharField(max_length=120, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.key
class SMSLog(models.Model):
"""لاگ سطح-تلاش برای هر بار ارسال توسط worker."""
class Meta:
verbose_name = "لاگ پیامک"
verbose_name_plural = "لاگ‌های پیامک"
ordering = ["-created_at"]
queue = models.ForeignKey(
SMSQueue, on_delete=models.CASCADE, related_name="logs", verbose_name="صف",
)
attempt_no = models.PositiveSmallIntegerField(default=1, verbose_name="شماره تلاش")
success = models.BooleanField(default=False, verbose_name="موفق")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
error = models.TextField(blank=True, default="", verbose_name="خطا")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
state = "موفق" if self.success else "ناموفق"
return f"#{self.queue_id} تلاش {self.attempt_no} ({state})"
+148
View File
@@ -0,0 +1,148 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.contrib.auth.models import User
class StoreRoom(models.Model):
class Meta:
permissions = [
("ViewStore", "ViewStore"),
("AddStore", "AddStore"),
("EditStore", "EditStore"),
("DelStore", "DelStore")
]
store_name = models.CharField(max_length=200)
user = models.ManyToManyField(User,
blank=True,
verbose_name="دسترسی مشاهده انبار احتمالا",
help_text="از این برای دسترسی مشاهده آن اتبار استفاده می شود"
)
owners = models.ManyToManyField(
User,
through='StoreRoomOwner', # کلید حل مشکل بدون شکستن کدهای قبلی
blank=True,
related_name="owned_storerooms",
verbose_name="مالکین انبار در فاکتور و موجودی و...",
help_text="این کاربران اگر فاکتور بزنند از این انبار کسر می‌گردد"
)
def __str__(self):
return self.store_name
class StoreRoomOwner(models.Model):
"""
این جدول واسط، پشت صحنه قرار می‌گیرد تا فقط قانون مالکیت را مدیریت کند.
کدهای دیگر پروژه اصلاً نیازی ندارند مستقیم با این جدول درگیر شوند.
"""
storeroom = models.ForeignKey(StoreRoom, on_delete=models.CASCADE)
# OneToOneField تضمین می‌کند یک کاربر نمی‌تواند در این جدول (و هیچ انبار دیگری) تکرار شود
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
error_messages={
'unique': 'این کاربر در حال حاضر مالک انبار دیگری است.'
}
)
class ProductStockHistory(models.Model):
store_room = models.ForeignKey(StoreRoom, on_delete=models.CASCADE, related_name='stock_histories')
product = models.ForeignKey('Product', on_delete=models.CASCADE, related_name='stock_histories')
quantity_changed = models.IntegerField(help_text="مقدار تغییر یافته (مثبت برای ورود، منفی برای خروج)")
current_stock = models.IntegerField(help_text="موجودی کل انبار در این لحظه")
created_at = models.DateTimeField(auto_now_add=True)
desc = models.CharField(max_length=255, blank=True, null=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.product.product_name} in {self.store_room.store_name} - Stock: {self.current_stock}"
class StoreTransferRequest(models.Model):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار تایید انبار مقصد'
APPROVED = 'APPROVED', 'تایید شده و منتقل شده'
REJECTED = 'REJECTED', 'رد شده توسط انبار مقصد'
CANCELED = 'CANCELED', 'انصراف توسط مبدا'
source_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='outgoing_transfers',
verbose_name="انبار مبدا"
)
target_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='incoming_transfers',
verbose_name="انبار مقصد"
)
product = models.ForeignKey(
'Product',
on_delete=models.PROTECT,
verbose_name="کالا"
)
quantity = models.PositiveIntegerField(verbose_name="تعداد انتقال")
status = models.CharField(
max_length=15,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
verbose_name="وضعیت"
)
desc = models.CharField(max_length=500, blank=True, null=True, verbose_name="توضیحات درخواست")
rejection_reason = models.CharField(max_length=500, blank=True, null=True, verbose_name="دلیل رد درخواست")
requester = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='requested_transfers',
verbose_name="کاربر درخواست‌دهنده"
)
approver = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='processed_transfers',
verbose_name="کاربر تعیین‌تکلیف‌کننده"
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="زمان ثبت درخواست")
updated_at = models.DateTimeField(auto_now=True, verbose_name="زمان آخرین به‌روزرسانی")
action_date = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="زمان تعیین تکلیف")
out_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_out_ref',
verbose_name="سند خروج انبار مبدا"
)
in_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_in_ref',
verbose_name="سند ورود انبار مقصد"
)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"[{self.get_status_display()}] {self.quantity} عدد {self.product.product_name} از {self.source_store.store_name} به {self.target_store.store_name}"
def clean(self):
super().clean()
if self.source_store_id == self.target_store_id:
raise ValidationError("انبار مبدا و مقصد نمی‌توانند یکسان باشند.")
if self.quantity and self.quantity <= 0:
raise ValidationError("تعداد انتقال باید بیشتر از صفر باشد.")
+51
View File
@@ -0,0 +1,51 @@
from django.db import models
class CustomerNameFixJob(models.Model):
"""وضعیت و checkpoint عملیات «افزودن پیشوند آقای/خانم به نام مشتریان».
این مدل **فقط** برای مدیریت/بازیابی job است و هیچ اطلاعاتی دربارهٔ جنسیت یا
هویت مشتری نگه نمی‌دارد. مدل ``Customer`` هیچ تغییر ساختاری نمی‌گیرد.
"""
class Status(models.TextChoices):
RUNNING = "running", "در حال اجرا"
PAUSED = "paused", "متوقف‌شده (قابل ادامه)"
COMPLETED = "completed", "تکمیل‌شده"
FAILED = "failed", "ناموفق"
# شناسهٔ فایل ورودی: اگر hash تغییر کند، ادامهٔ job رد می‌شود.
source_hash = models.CharField(max_length=64)
algorithm_version = models.CharField(max_length=16, default="1")
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PAUSED)
batch_size = models.PositiveIntegerField(default=200)
# دامنهٔ job: بیشترین primary key مشتریان در لحظهٔ آغاز. مشتریان جدیدتر
# (pk > initial_max_pk) عمداً خارج از دامنه می‌مانند.
initial_max_pk = models.BigIntegerField(null=True, blank=True)
# checkpoint: آخرین pk پردازش‌شده (به‌صورت اتمیک با تغییرات همان batch ثبت می‌شود).
last_processed_pk = models.BigIntegerField(default=0)
stats = models.JSONField(default=dict, blank=True)
note = models.CharField(max_length=255, blank=True, default="")
started_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
finished_at = models.DateTimeField(null=True, blank=True)
# ضربان قفل: برای تشخیص job رهاشده پس از crash و جلوگیری از اجرای هم‌زمان.
heartbeat_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "job اصلاح نام مشتری"
verbose_name_plural = "jobهای اصلاح نام مشتری"
def __str__(self):
return f"job#{self.pk} {self.status} hash={self.source_hash[:8]} pk<={self.last_processed_pk}"
class CacheTable(models.Model):
key = models.CharField(max_length=50, unique=True)
value = models.CharField(max_length=400)
def __str__(self):
return f'{self.key} # {self.value}'
+18
View File
@@ -0,0 +1,18 @@
from django.db import models
from django.contrib.auth.models import User
# اصلاح مدل کاربر: تبدیل به پروفایل برای حفظ امنیت و اتصال به سیستم پیش‌فرض جنگو
class UserProfile(models.Model):
UserTypeList = [
('admin', 'admin'),
('office', 'office'),
('operator', 'operator'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
user_type = models.CharField(max_length=10, choices=UserTypeList)
name_family = models.CharField(max_length=200)
create_date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.user.username} ({self.user_type})"
+205
View File
@@ -0,0 +1,205 @@
"""منطق محضِ افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ ``iranianNamesDataset.json``.
این ماژول هیچ چیزی در دیتابیس تغییر نمی‌دهد و به Django وابسته نیست؛ فقط:
* فایل JSON را **یک بار** می‌خواند و SHA-256 و index جست‌وجو می‌سازد.
* برای هر نام تصمیم می‌گیرد که آیا پیشوند اضافه شود یا نه (سیاست کاملاً محافظه‌کارانه).
قواعد تصمیم (به ترتیب):
1. نام تهی/فقط فاصله → دست‌نخورده.
2. اولین کلمه یکی از ``آقا``/``آقای``/``خانم`` → دست‌نخورده (پیشوند تکرار نمی‌شود).
3. نام تک‌کلمه‌ای → دست‌نخورده (ممکن است فقط نام خانوادگی باشد).
4. اولین کلمه در index نباشد → دست‌نخورده.
5. جنسیت اولین کلمه مبهم/نامعتبر باشد → دست‌نخورده.
6. در غیر این‌صورت پیشوند قطعی افزوده می‌شود.
نرمال‌سازی صرفاً برای کلید جست‌وجو است (NFC + یکسان‌سازی ``ي/ی`` و ``ك/ک``).
از fuzzy matching، حذف حروف یا حدس جنسیت استفاده نمی‌شود.
"""
import hashlib
import json
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional
# نسخهٔ الگوریتم؛ با هر تغییر در قواعد تصمیم یا نرمال‌سازی باید افزایش یابد تا
# jobهای قبلی به‌اشتباه ادامه داده نشوند.
ALGORITHM_VERSION = "1"
PREFIX_MALE = "آقای"
PREFIX_FEMALE = "خانم"
# پیشوندهایی که اگر ابتدای نام باشند، نام دست‌نخورده می‌ماند.
KNOWN_PREFIXES = frozenset({"آقا", "آقای", "خانم"})
# تنها مقادیر معتبر Gender در فایل داده. هر مقدار دیگری «نامعتبر» شمرده می‌شود.
GENDER_TO_PREFIX = {"M": PREFIX_MALE, "F": PREFIX_FEMALE}
_ARABIC_YEH = "\u064a"
_PERSIAN_YEH = "\u06cc"
_ARABIC_KAF = "\u0643"
_PERSIAN_KAF = "\u06a9"
class DatasetError(ValueError):
"""ساختار فایل داده خراب است؛ هیچ تغییری مجاز نیست."""
def normalize_name_key(value) -> str:
"""کلید جست‌وجو: NFC + حذف فاصلهٔ ابتدا/انتها + یکسان‌سازی ي/ی و ك/ک."""
if not isinstance(value, str):
return ""
text = unicodedata.normalize("NFC", value).strip()
return text.replace(_ARABIC_YEH, _PERSIAN_YEH).replace(_ARABIC_KAF, _PERSIAN_KAF)
@dataclass
class NameIndex:
"""نتیجهٔ خواندن فایل داده: index قابل‌استفاده + گزارش اعتبارسنجی."""
path: str
sha256: str
index: Dict[str, str] = field(default_factory=dict) # کلید نرمال‌شده → 'M'/'F'
ambiguous: frozenset = frozenset() # کلیدهایی که قطعی نیستند
report: Dict[str, int] = field(default_factory=dict)
def lookup(self, key: str) -> Optional[str]:
"""جنسیت قطعیِ یک کلید نرمال‌شده، یا ``None`` اگر ناشناخته/مبهم باشد."""
return self.index.get(key)
@dataclass
class Decision:
"""تصمیم برای یک نام. ``new_name`` تنها هنگام تصمیم قطعی پر می‌شود."""
new_name: Optional[str]
reason: str # male | female | single_word | has_prefix | empty | unknown_name | ambiguous_gender
def validate_dataset(doc) -> list:
"""اعتبارسنجی ساختار کلی JSON. آرایهٔ ``RECORDS`` را برمی‌گرداند.
در صورت خرابی ساختار (JSON غیر دیکشنری، نبودِ ``RECORDS`` یا لیست نبودن آن)
``DatasetError`` پرتاب می‌شود؛ در این حالت هیچ نام مشتری نباید تغییر کند.
"""
if not isinstance(doc, dict):
raise DatasetError("ریشهٔ فایل JSON یک شیء (object) نیست.")
if "RECORDS" not in doc:
raise DatasetError("کلید «RECORDS» در فایل JSON وجود ندارد.")
records = doc["RECORDS"]
if not isinstance(records, list):
raise DatasetError("مقدار «RECORDS» یک آرایه (list) نیست.")
return records
def build_name_index(records, *, path="", sha256="") -> NameIndex:
"""از آرایهٔ رکوردها، index محافظه‌کارانه و گزارش اعتبارسنجی می‌سازد.
سیاست: یک کلید نرمال‌شده تنها وقتی قابل‌استفاده است که **همهٔ** رکوردهایش
جنسیت معتبر (``M``/``F``) و یکسان داشته باشند. هر رکورد نامعتبر، جنسیت
ناشناخته، یا تناقض ``M``/``F`` ⇒ کلید کاملاً کنار گذاشته می‌شود.
"""
groups: Dict[str, Dict[str, object]] = {}
report = {
"records_total": 0,
"rejected_malformed": 0, # رکورد غیردیکشنری یا Names/Gender غیررشته‌ای
"rejected_empty_name": 0, # نام خالی پس از strip
"rejected_unknown_gender": 0, # مقدار Gender خارج از M/F
"duplicate_keys": 0,
"duplicate_same_gender": 0,
"duplicate_conflict": 0,
"usable_keys": 0,
"unusable_keys": 0,
}
for record in records:
report["records_total"] += 1
if not isinstance(record, dict):
report["rejected_malformed"] += 1
continue
names = record.get("Names")
gender = record.get("Gender")
if not isinstance(names, str) or not isinstance(gender, str):
report["rejected_malformed"] += 1
continue
key = normalize_name_key(names)
if not key:
report["rejected_empty_name"] += 1
continue
entry = groups.setdefault(key, {"gender": None, "invalid": False, "count": 0})
entry["count"] += 1
if gender in GENDER_TO_PREFIX:
if entry["gender"] is not None and entry["gender"] != gender:
entry["invalid"] = True # تناقض M/F
else:
entry["gender"] = gender
else:
entry["invalid"] = True # جنسیت ناشناخته/نامعتبر
report["rejected_unknown_gender"] += 1
index: Dict[str, str] = {}
ambiguous = set()
for key, entry in groups.items():
if entry["count"] > 1:
report["duplicate_keys"] += 1
if entry["invalid"]:
report["duplicate_conflict"] += 1
else:
report["duplicate_same_gender"] += 1
if entry["invalid"] or entry["gender"] is None:
ambiguous.add(key)
else:
index[key] = entry["gender"]
report["usable_keys"] = len(index)
report["unusable_keys"] = len(ambiguous)
return NameIndex(path=path, sha256=sha256, index=index,
ambiguous=frozenset(ambiguous), report=report)
def load_name_index(path) -> NameIndex:
"""فایل را **یک بار** می‌خواند، hash و index می‌سازد.
برای محاسبهٔ SHA-256 فایل جداگانه دوباره خوانده نمی‌شود.
"""
resolved = Path(path)
raw = resolved.read_bytes() # تنها خواندن فایل
digest = hashlib.sha256(raw).hexdigest()
try:
doc = json.loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc:
raise DatasetError(f"فایل JSON با UTF-8 خوانده نشد: {exc}") from exc
except json.JSONDecodeError as exc:
raise DatasetError(f"JSON نامعتبر است: {exc}") from exc
records = validate_dataset(doc)
return build_name_index(records, path=str(resolved), sha256=digest)
def decide_prefix(raw_name, name_index: NameIndex) -> Decision:
"""تصمیم برای یک مقدار ``name_family``. هیچ‌گاه خودِ مقدار را تغییر نمی‌دهد."""
if not isinstance(raw_name, str):
return Decision(None, "empty")
stripped = raw_name.strip()
if not stripped:
return Decision(None, "empty")
tokens = stripped.split()
if tokens[0] in KNOWN_PREFIXES:
return Decision(None, "has_prefix")
if len(tokens) < 2:
return Decision(None, "single_word")
key = normalize_name_key(tokens[0])
gender = name_index.lookup(key)
if gender is None:
reason = "ambiguous_gender" if key in name_index.ambiguous else "unknown_name"
return Decision(None, reason)
prefix = GENDER_TO_PREFIX[gender]
return Decision(f"{prefix} {stripped}", "male" if gender == "M" else "female")
+19
View File
@@ -0,0 +1,19 @@
"""سیگنال‌های اپ admin_panel."""
import logging
from django.db.backends.signals import connection_created
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(connection_created)
def configure_sqlite_connection(sender, connection, **kwargs):
"""افزایش busy_timeout برای SQLite تا نوشتن هم‌زمان web/worker/scheduler خطا ندهد."""
if connection.vendor != "sqlite":
return
try:
with connection.cursor() as cursor:
cursor.execute("PRAGMA busy_timeout=30000;")
except Exception:
logger.warning("تنظیم busy_timeout برای SQLite ناموفق بود.", exc_info=True)
+1
View File
@@ -0,0 +1 @@
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
+44
View File
@@ -0,0 +1,44 @@
"""مانده حساب مشتری برای استفاده در قالب‌های پیامک.
تعریف (همان قاعدهٔ صفحهٔ حسابداری مشتری و گزارش مالی)::
مانده حساب = مجموع بدهکار (debit) − مجموع بستانکار (credit)
نکتهٔ مهم — تفاوت با «مانده سررسیدشده»:
* **مانده حساب** (این ماژول): تراز کل همهٔ اسناد حسابداری مشتری (``Article``).
منبع: ``admin_panel/models/finance.py::Article``.
* **مانده/مبلغ سررسیدشده**: مبلغ چک‌ها و نسیه‌های پرداخت‌نشده در جدول
``Sarresid`` (``payStatus='false'``). بسیار متفاوت از مانده حساب است و
جای دیگری (``views/finance.py``) محاسبه می‌شود.
به همین دلیل این مقدار با کلید مستقل ``{balance}`` در قالب‌ها منتشر می‌شود و
هرگز با مبلغ سررسید (``{amount}`` در پیامک چک/نسیه) قاطی نمی‌شود.
"""
import logging
logger = logging.getLogger(__name__)
def customer_balance(customer):
"""مانده حساب مشتری (``debit − credit``) به‌صورت عدد صحیح.
اگر مشتری یا اسنادش وجود نداشته باشد ``0`` برمی‌گردد. هیچ استثنایی پرتاب
نمی‌کند تا مسیر ساخت پیامک هرگز مختل نشود.
"""
if customer is None or getattr(customer, "pk", None) is None:
return 0
try:
from django.db.models import Sum
from ..models import Article
agg = Article.objects.filter(customer=customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
debit = agg["d"] or 0
credit = agg["c"] or 0
return debit - credit
except Exception:
logger.exception("محاسبهٔ مانده حساب مشتری %s شکست خورد", customer.pk)
return 0
+83
View File
@@ -0,0 +1,83 @@
"""قرار دادن پیامک در صف ارسال (insert-only، بی‌خطر برای مسیر request)."""
import logging
from uuid import uuid4
from django.db import IntegrityError
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
from .balance import customer_balance
from .recipients import resolve_recipients
from .render import render_template
from .rules import is_sms_enabled
logger = logging.getLogger(__name__)
def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None,
message=None, recipients=None, created_by=None, approved=False):
"""پیامک را در صف قرار می‌دهد و تعداد رکوردهای ساخته‌شده را برمی‌گرداند.
به‌صورت پیش‌فرض پیامک در وضعیت «در انتظار تأیید» ثبت می‌شود و تا وقتی مدیر
آن را از پنل تأیید نکند، worker آن را ارسال نمی‌کند. دو راه برای پرش از این
بازبینی هست: ``approved=True`` برای ارسال‌های دستیِ صریح مدیر (مثل پیامک سفارشی
یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالب‌ها برای هر نوع
پیامک روشن می‌کند تا پیامک‌های خودکارِ اسکنر بدون صف تأیید ارسال شوند.
متغیرهای مشترکِ همهٔ قالب‌ها (مثل ``{balance}`` = مانده حساب مشتری) همین‌جا و
یک‌بار به ``context`` افزوده می‌شوند تا هر قالبی بتواند از آن‌ها استفاده کند؛
فراخوان‌ها لازم نیست هر بار آن‌ها را بسازند. اگر فراخوان خودش مقداری داده باشد
(``context``) همان اولویت دارد.
هیچ استثنایی به فراخوان پرتاب نمی‌شود؛ خطاها فقط لاگ می‌شوند تا مسیر اصلی
(مثلاً ایجاد مشتری یا اسکنر) هرگز به‌خاطر پیامک مختل نشود.
"""
try:
if message is None:
template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
if template is None:
return 0
ctx = dict(context or {})
# متغیرهای سراسری قالب‌ها؛ هرگز مقدار فراخوان را بازنویسی نمی‌کنند.
ctx.setdefault("balance", customer_balance(customer))
message = render_template(template.text, ctx)
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالب‌ها تعیین می‌شود؛ خاموش = بازبینی مدیر.
approved = approved or template.auto_send
if not is_sms_enabled(customer, sms_type):
return 0
numbers = recipients if recipients is not None else resolve_recipients(customer)
if not numbers:
return 0
if dedup_key is None:
dedup_key = f"{sms_type}:{uuid4().hex}"
created = 0
for number in numbers:
key = dedup_key if len(numbers) == 1 else f"{dedup_key}:{number}"
try:
# دفتر کلیدها مستقل از صف است: کلیدِ یک‌بار خورده هرگز دوباره
# ایجاد نمی‌شود، حتی اگر رکورد صف را مدیر حذف کرده باشد.
if not SMSDedup.objects.get_or_create(key=key)[1]:
continue
_, made = SMSQueue.objects.get_or_create(
dedup_key=key,
defaults={
"customer": customer,
"sms_type": sms_type,
"phone_number": number,
"message": message,
"created_by": created_by,
"status": (SMSQueue.Status.APPROVED if approved
else SMSQueue.Status.PENDING),
},
)
created += 1 if made else 0
except IntegrityError:
# رقابت هم‌زمان روی dedup_key؛ نادیده گرفته می‌شود.
continue
return created
except Exception:
logger.exception("enqueue_sms برای نوع=%s شکست خورد", sms_type)
return 0
+63
View File
@@ -0,0 +1,63 @@
"""پیام‌های نتیجهٔ عملیات پنل پیامک.
جایگزین flash message جنگو است: پیام‌های جنگو در session ذخیره می‌شوند و اگر
قالب مقصد آن‌ها را رندر نکند، تا صفحهٔ بعدِ دارای بلوک پیام «سرگردان» می‌مانند و
بی‌ربط به عملیات ظاهر می‌شوند. این‌جا پیام با یک کد در querystring
(``?notice=<code>``) رد و بدل می‌شود؛ چون کد فقط روی همان URL وجود دارد، پیام هم
فقط همان‌جا دیده می‌شود و هیچ‌جا سرگردان نمی‌ماند.
مقدار ``{placeholder}`` در متن از پارامترهای querystring پر می‌شود
(نبودِ پارامتر ⇒ رشتهٔ خالی).
"""
from collections import defaultdict
# کد → (شدت، متن). شدت به کلاس Bulma نگاشت می‌شود.
NOTICES = {
# --- تنظیمات و ارسال آزمایشی ---
"test_sent": ("success", "پیامک آزمایشی در صف ارسال قرار گرفت."),
"settings_saved": ("success", "تنظیمات سامانه پیامک ذخیره شد."),
# --- قالب‌ها ---
"tpl_saved": ("success", "قالب «{title}» ذخیره شد."),
"tpl_bad_timing": ("error", "مقدار زمان‌بندی باید یک عدد صحیح باشد."),
# --- مشتری ---
"customer_sms_saved": ("success", "تنظیم پیامک مشتری به‌روزرسانی شد."),
"primary_updated": ("success", "شمارهٔ اصلی پیامک به‌روزرسانی شد."),
"primary_not_found": ("error", "شماره موردنظر برای این مشتری یافت نشد."),
"custom_queued": ("success", "پیامک سفارشی در صف ارسال قرار گرفت."),
"custom_empty": ("error", "متن پیامک خالی است."),
"custom_no_phone": ("error", "شماره اصلی معتبری برای این مشتری یافت نشد."),
# --- صف ---
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
"deleted": ("success", "پیامک از صف حذف شد."),
"delete_done_blocked": (
"warning",
"این پیامک ارسال شده است و از صف حذف نمی‌شود؛ سابقهٔ آن در «گزارش ارسال» می‌ماند.",
),
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
"invalid_action": ("error", "اقدام نامعتبر است."),
"queue_emptied": ("success", "کل صف خالی شد ({count} پیامک حذف شد)."),
"retried": ("success", "پیامک برای ارسال مجدد تأیید شد."),
}
_KIND_CLASSES = {
"success": "is-success",
"error": "is-danger",
"warning": "is-warning",
"info": "is-info",
}
def resolve(request):
"""کد ``?notice=`` را به متن/کلاس قابل رندر در قالب تبدیل می‌کند."""
get = getattr(request, "GET", None) or {}
entry = NOTICES.get((get.get("notice") or "").strip())
if not entry:
return {}
kind, text = entry
return {
"notice_text": text.format_map(defaultdict(str, get)),
"notice_kind": _KIND_CLASSES.get(kind, "is-info"),
}
+90
View File
@@ -0,0 +1,90 @@
"""محاسبهٔ «تعداد پیامکِ محاسبه‌شده» (parts) و کدگذاری متن.
هر پیامک روی شبکهٔ موبایل بر اساس **کدگذاری** و **طول** به یک یا چند «بخش» (part)
شکسته می‌شود و اپراتور بر همان تعداد بخش هزینه می‌گیرد. این ماژول همان قاعدهٔ
استاندارد (3GPP/GSM 03.38) را پیاده می‌کند تا بتوان هزینهٔ ارسال را تحلیل کرد:
* **GSM-7** — برای متن‌های انگلیسی/عددی. هر بخش ۱۶۰ کاراکتر؛ در حالت چندبخشی
(multipart) هر بخش ۱۵۳ کاراکتر (۷ کاراکتر برای هدر الحاق).
برخی کاراکترها (مثل `{}[]~\\|^€`) در جدول «الحاقی» هستند و **۲ کاراکتر** حساب
می‌شوند.
* **UCS-2** — اگر متن حتی **یک** کاراکتر خارج از جدول GSM-7 داشته باشد (فارسی،
عربی، ایموجی…)، **کل پیامک** با UCS-2 کدگذاری می‌شود: هر بخش ۷۰ کاراکتر؛ در
حالت چندبخشی هر بخش ۶۷ کاراکتر. ایموجی‌ها (خارج از BMP) دو واحد کدِ UTF-16
حساب می‌شوند.
بنابراین یک متن فارسی حتی اگر کوتاه باشد، در «۷۰ کاراکتر» جا می‌شود و ممکن است
چند پیامک حساب شود؛ و یک متن ترکیبی فارسی+انگلیسی همیشه UCS-2 است.
"""
from math import ceil
ENCODING_GSM7 = "gsm7"
ENCODING_UCS2 = "ucs2"
# جدول کامل GSM 03.38 (بخش پایه). کاراکتر 0x1B عمداً وارد نشده چون نقش «پیشوند
# الحاق» دارد و کاراکتر مستقل قانونی نیست.
GSM7_BASIC = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ"
" !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
# کاراکترهای «جدول الحاقی»: در GSM-7 دو واحد حساب می‌شوند.
GSM7_EXTENDED = set("^{}\\[~]|€")
# (سقف تک‌بخشی، سقف هر بخش چندبخشی) برای هر کدگذاری.
LIMITS = {
ENCODING_GSM7: (160, 153),
ENCODING_UCS2: (70, 67),
}
ENCODING_LABELS = {
ENCODING_GSM7: "GSM-7 (انگلیسی/عدد)",
ENCODING_UCS2: "UCS-2 (فارسی/یونیکد)",
}
def is_gsm7(text):
"""آیا کل متن با جدول GSM-7 قابل نمایش است؟ (شرط کافی و لازم برای GSM-7)."""
return all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text or "")
def detect_encoding(text):
"""کدگذاری متن: ``gsm7`` یا ``ucs2``. متن خالی = ``gsm7``."""
return ENCODING_GSM7 if is_gsm7(text) else ENCODING_UCS2
def encoded_length(text):
"""طول متن بر حسب «واحدهایی که اپراتور می‌شمارد».
* GSM-7: کاراکترهای الحاقی ۲ واحد.
* UCS-2: واحدهای کد UTF-16 (ایموجی = ۲).
"""
text = text or ""
if is_gsm7(text):
return sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)
return len(text.encode("utf-16-le")) // 2
def parts_count(text):
"""تعداد بخش‌هایی که اپراتور برای این متن حساب می‌کند (۱ برای متن خالی = ۰)."""
text = text or ""
if not text:
return 0
single, multipart = LIMITS[detect_encoding(text)]
length = encoded_length(text)
if length <= single:
return 1
return ceil(length / multipart)
def describe(text):
"""خلاصهٔ محاسبه برای نمایش در گزارش (کدگذاری، طول، تعداد بخش)."""
encoding = detect_encoding(text)
return {
"encoding": encoding,
"encoding_label": ENCODING_LABELS[encoding],
"length": encoded_length(text),
"parts": parts_count(text),
}
+139
View File
@@ -0,0 +1,139 @@
"""پاک‌سازی یک‌بارهٔ شماره‌های قدیمی مشتریان (فیلدهای ``phone1``/``phone2``/``phone3``).
سال‌ها شماره‌ها در سه فیلد متنی ذخیره شده‌اند و به هر شکلی وارد شده‌اند:
بدون صفر ابتدایی (``9133722815``)، با ارقام فارسی، با فاصله/خط تیره، یا کلاهی
متن آزاد (نام، تاریخ نصب، «معرف …»). این ماژول قاعدهٔ تبدیل را یک‌جا تعریف می‌کند
تا هم مهاجرت (اجرای خودکار، یک‌بار) و هم دستور ``fix_customer_phones`` (اجرای
دستی روی دیتابیس production) از یک پیاده‌سازی استفاده کنند.
قاعده (طبق تصمیم کاربر):
* شمارهٔ موبایل — هرجای متن که باشد — استخراج و به شکل استاندارد ``09xxxxxxxxx``
تبدیل می‌شود.
* شمارهٔ ثابت ۸رقمی (مثل ``57387608``) **دست‌نخورده** می‌ماند.
* بقیه (متن آزاد، تاریخ، تکه‌های بی‌معنی) از فیلد شماره خالی می‌شود و متن اصلی‌اش
در فیلد ``Customer.notes`` می‌رود تا چیزی از سیستم گم نشود.
تابع ``clean_legacy_cell`` خودبسنده و idempotent است: بعد از یک‌بار اجرا، خروجی
همان مقدار فعلی است و دیگر توضیحی اضافه نمی‌شود؛ پس اجرای دوباره بی‌خطر است.
"""
import re
from persiantools import digits
from integrations.sms.normalize import normalize_mobile, to_local
_NOTE_HEADER = "— پاک‌سازی خودکار فیلدهای تلفن —"
def _numeric_tokens(raw):
"""تکه‌های رقمی متن، پس از تبدیل ارقام فارسی/عربی به انگلیسی."""
text = digits.fa_to_en(digits.ar_to_fa(str(raw)))
return re.findall(r"\d+", text)
def clean_legacy_cell(raw):
"""یک فیلد تلفن را پاک‌سازی می‌کند.
خروجی ``(value, note)`` است:
* ``value`` مقدار جدید فیلد، یا ``None`` یعنی «دست نزن».
* ``note`` متن اصلی برای افزودن به ``Customer.notes``، یا ``None``.
قاعده: هرچیزِ فیلد که در مقدار جدید نمی‌ماند، **بی‌کم‌وکاست** در
``note`` برمی‌گردد تا هیچ داده‌ای گم نشود.
"""
original = (raw or "").strip()
if not original:
return None, None
# ۱) کل فیلد یک موبایل است (شاید با فاصله/خط تیره/پیش‌شماره).
whole = normalize_mobile(original)
if whole:
local = to_local(whole)
if local == original:
return None, None
return local, original
tokens = _numeric_tokens(original)
# ۲) موبایلی داخل متن پنهان است (مثل «57322182/9130261671» یا «موبایل: 0912…»).
for token in tokens:
local = to_local(token)
if local:
return local, original
# ۳) شمارهٔ ثابت ۸رقمیِ تنها ⇒ دست‌نخورده.
if len(tokens) == 1 and len(tokens[0]) == 8:
return None, None
# ۴) بقیه (متن آزاد، تاریخ، تکهٔ بی‌معنی) ⇒ خالی؛ متن اصلی در یادداشت می‌ماند.
return "", original
def _append_note(notes, lines):
"""خطوط توضیح را به انتهای یادداشت مشتری اضافه می‌کند (با سرصفحهٔ یک‌بار)."""
current = (notes or "").strip()
block = "\n".join(lines)
if _NOTE_HEADER in current:
return f"{current}\n{block}"
if current:
return f"{current}\n\n{_NOTE_HEADER}\n{block}"
return f"{_NOTE_HEADER}\n{block}"
def clean_customer(customer, *, dry_run=False):
"""فیلدهای تلفن یک مشتری را اصلاح و متن‌های حذف‌شده را در ``notes`` می‌ریزد.
خروجی: تعداد فیلدهای تغییرکرده. اگر تغییری نباشد، چیزی ذخیره نمی‌شود.
"""
changed = 0
note_lines = []
updates = {}
for field in ("phone1", "phone2", "phone3"):
value, note = clean_legacy_cell(getattr(customer, field, ""))
if value is not None and value != (getattr(customer, field) or ""):
updates[field] = value
changed += 1
if note:
note_lines.append(f"«{field}»: {note}")
if not changed and not note_lines:
return 0
if dry_run:
return changed or (1 if note_lines else 0)
if note_lines:
customer.notes = _append_note(customer.notes, note_lines)
updates["notes"] = customer.notes
if updates:
for field, value in updates.items():
setattr(customer, field, value)
customer.save(update_fields=list(updates))
return changed
def clean_all(*, dry_run=False, batch_size=500, apps=None):
"""همهٔ مشتریان را پاک‌سازی می‌کند. خروجی: دیکشنری آمار.
``apps`` فقط برای مهاجرت‌ها است تا مدل را از رجیستری تاریخی بگیرد (نه import
زمان‌اجرا)؛ در غیر این صورت مدل زندهٔ ``Customer`` استفاده می‌شود.
"""
if apps is not None:
Customer = apps.get_model("admin_panel", "Customer")
else:
from ..models import Customer
stats = {"customers": 0, "fields": 0, "noted": 0}
qs = Customer.objects.all().only(
"id", "phone1", "phone2", "phone3", "notes"
).order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
before_notes = (customer.notes or "").strip()
changed = clean_customer(customer, dry_run=dry_run)
if changed:
stats["customers"] += 1
stats["fields"] += changed
if (not dry_run) and (customer.notes or "").strip() != before_notes:
stats["noted"] += 1
return stats
+163
View File
@@ -0,0 +1,163 @@
"""یافتن شماره‌های یک مشتری از همهٔ روش‌های ذخیره‌سازی.
شماره‌ها در این پروژه به چند شکل ذخیره می‌شوند:
* رکوردهای مدل ``PhoneNumber`` (روش جدید؛ عنوان + شماره)
* فیلدهای قدیمی ``Customer.phone1`` / ``phone2`` / ``phone3``
* بدون صفر ابتدایی (``9133722815`` به‌جای ``09133722815``) و بعضاً مقدارهای
بی‌معنی مثل ``'0'`` یا شمارهٔ ثابت.
این ماژول تنها مرجع خواندن شماره‌هاست: هر جای دیگر (پنل، اسکنر، worker) باید از
همین توابع استفاده کند تا رفتار یکسان باشد.
«شمارهٔ اصلی» به‌صورت **مقدارِ خودِ شماره** (نرمال‌شده) در ``Customer.sms_primary_phones``
ذخیره می‌شود، نه با کلید رکورد. دلیل: دادهٔ مشتریان دائماً تغییر می‌کند (افزودن، ویرایش،
حذف) و کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره یک مقدار پایدار است. اگر شماره‌ای
از مشتری حذف یا ویرایش شود، انتخاب اصلی خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول
برمی‌گردد.
"""
from integrations.sms.normalize import normalize_mobile
class PhoneEntry:
"""یک شمارهٔ مشتری، مستقل از اینکه کجا ذخیره شده است."""
__slots__ = ("raw", "display", "normalized", "title", "source", "is_primary")
def __init__(self, raw, title, source, is_primary=False):
self.raw = raw
self.title = title
self.source = source
self.normalized = normalize_mobile(raw)
self.display = to_display(raw)
self.is_primary = is_primary
@property
def is_mobile(self):
return bool(self.normalized)
def as_dict(self):
return {
"raw": self.raw,
"display": self.display,
"normalized": self.normalized,
"title": self.title,
"source": self.source,
"is_primary": self.is_primary,
"is_mobile": self.is_mobile,
}
def __repr__(self): # pragma: no cover - کمک‌کنندهٔ دیباگ
return f"<PhoneEntry {self.display!r} primary={self.is_primary} source={self.source}>"
def to_display(raw):
"""شماره را برای نمایش با صفر ابتدایی برمی‌گرداند (بدون دست‌بردن در مقدار ذخیره‌شده)."""
text = "" if raw is None else str(raw).strip()
if not text:
return ""
normalized = normalize_mobile(text)
if normalized:
return "0" + normalized[3:] # +989xxxxxxxxx → 09xxxxxxxxx
return text
def _iter_sources(customer):
"""(title, raw, source) از همهٔ منابع، بدون تکرار — ابتدا رکوردها، سپس فیلدهای قدیمی."""
from ..models import PhoneNumber
seen = set()
rows = PhoneNumber.objects.filter(customer=customer).order_by("id")
for row in rows:
raw = (row.phone_number or "").strip()
if not raw:
continue
key = normalize_mobile(raw) or raw
if key in seen:
continue
seen.add(key)
yield (row.title or "تلفن", raw, "phone_number")
legacy = (
("تلفن 1", getattr(customer, "phone1", "")),
("تلفن 2", getattr(customer, "phone2", "")),
("تلفن 3", getattr(customer, "phone3", "")),
)
for title, raw in legacy:
raw = (raw or "").strip()
if not raw:
continue
key = normalize_mobile(raw) or raw
if key in seen:
continue
seen.add(key)
yield (title, raw, "legacy")
def stored_primary_normalized(customer):
"""مجموعهٔ شماره‌های نرمال‌شده‌ای که مدیر به‌عنوان «اصلی» انتخاب کرده است."""
raw = (getattr(customer, "sms_primary_phones", "") or "").strip()
if not raw:
return set()
return {part.strip() for part in raw.split(",") if part.strip()}
def normalize_primary_values(values):
"""فهرست شماره‌های خام → رشتهٔ نرمال‌شدهٔ یکتا برای ذخیره در ``sms_primary_phones``.
شماره‌های نامعتبر (ثابت، ناقص، ``'0'``) نادیده گرفته می‌شوند؛ پس علامت‌زدن یک شمارهٔ
غیرموبایل به‌عنوان «اصلی» بی‌اثر می‌ماند و سیستم به شمارهٔ اولِ معتبر برمی‌گردد.
"""
unique = []
for raw in values:
number = normalize_mobile(raw)
if number and number not in unique:
unique.append(number)
return ",".join(unique)
def save_primary_normalized(customer, numbers, *, update_fields=("sms_primary_phones",)):
"""ذخیرهٔ فهرست شماره‌های اصلی (نرمال‌شده، بدون تکرار، به ترتیب ورودی)."""
customer.sms_primary_phones = normalize_primary_values(numbers)
customer.save(update_fields=list(update_fields))
return stored_primary_normalized(customer)
def customer_phone_entries(customer):
"""همهٔ شماره‌های مشتری از هر منبعی که ذخیره شده باشند (بدون تکرار)."""
if customer is None:
return []
primary = stored_primary_normalized(customer)
return [
PhoneEntry(raw, title, source, is_primary=(entry_norm in primary) if entry_norm else False)
for title, raw, source in _iter_sources(customer)
for entry_norm in [normalize_mobile(raw)]
]
def usable_entries(customer):
"""فقط شماره‌هایی که موبایل معتبرند و واقعاً قابل ارسال پیامک هستند."""
return [entry for entry in customer_phone_entries(customer) if entry.is_mobile]
def primary_phones(customer):
"""شماره‌های خام گیرندهٔ پیامک: انتخاب‌شده‌های مدیر، وگرنه شمارهٔ اولِ قابل استفاده."""
entries = usable_entries(customer)
if not entries:
return []
chosen = [entry for entry in entries if entry.is_primary]
if chosen:
return [entry.raw for entry in chosen]
return [entries[0].raw]
def resolved_recipients(customer):
"""گیرندگان نهاییِ نرمال‌شده (``+989xxxxxxxxx``) و یکتا."""
seen = []
for entry in usable_entries(customer):
if entry.is_primary and entry.normalized not in seen:
seen.append(entry.normalized)
if seen:
return seen
entries = usable_entries(customer)
return [entries[0].normalized] if entries else []
+22
View File
@@ -0,0 +1,22 @@
"""تعیین گیرندگان پیامک.
منطق واقعیِ یافتن شماره‌ها در ``admin_panel.sms.phones`` است (تنها مرجع خواندن
شماره‌ها). این ماژول فقط نام‌های قدیمی را برای سازگاری نگه می‌دارد و آن‌ها را به
همان توابع واگذار می‌کند تا رفتار در همهٔ مسیرها یکسان بماند.
"""
import logging
from .phones import primary_phones as _primary_phones
from .phones import resolved_recipients
logger = logging.getLogger(__name__)
def primary_phones(customer):
"""شماره‌های خام گیرندهٔ پیامک (اصلی‌های انتخاب‌شده، وگرنه شمارهٔ اولِ معتبر)."""
return _primary_phones(customer)
def resolve_recipients(customer):
"""فهرست شماره‌های نرمال‌شدهٔ یکتای گیرنده."""
return resolved_recipients(customer)
+55
View File
@@ -0,0 +1,55 @@
"""رندر قالب متن پیامک با placeholderهای امن."""
from persiantools.jdatetime import JalaliDate, JalaliDateTime
class _SafeDict(dict):
"""دیکشنری امن برای ``str.format_map``.
* کلیدِ ناشناخته ⇒ رشتهٔ خالی (بدون ``KeyError``).
* کلیدِ شناخته‌شده با مقدار ``None`` ⇒ رشتهٔ خالی. فیلدهای اختیاری مدل
(مثل ``Sarresid.check_sayyad_id`` یا ``check_row_number``) ممکن است ``NULL``
باشند؛ بدون این تبدیل، ``None`` عیناً در متن پیامک چاپ می‌شد.
"""
def __missing__(self, key):
return ""
def __getitem__(self, key):
value = super().__getitem__(key) if key in self else ""
return "" if value is None else value
def format_amount(value):
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return str(value or "")
def format_jalali(value):
"""تبدیل datetime/date به تاریخ جلالی ``YYYY/MM/DD``."""
if not value:
return ""
try:
if hasattr(value, "date") and callable(getattr(value, "date")):
value = value.date()
return JalaliDate(value).strftime("%Y/%m/%d")
except Exception:
try:
return JalaliDateTime(value).strftime("%Y/%m/%d")
except Exception:
return str(value)
def render_template(text, context=None):
ctx = dict(context or {})
if "amount" in ctx:
ctx.setdefault("amount_sep", format_amount(ctx["amount"]))
if "balance" in ctx:
# مثل «مبلغ»، نسخهٔ جداکننده‌دار هم آماده می‌شود تا در قالب‌ها
# ``{balance_sep}`` در دسترس باشد (۳٬۵۰۰٬۰۰۰ → "3,500,000").
ctx.setdefault("balance_sep", format_amount(ctx["balance"]))
if "date" in ctx:
ctx.setdefault("date_raw", ctx["date"])
ctx["date"] = format_jalali(ctx["date"])
return (text or "").format_map(_SafeDict(ctx))
+24
View File
@@ -0,0 +1,24 @@
"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out)."""
from ..models import CustomerSMSSetting, SMSType
def is_sms_enabled(customer, sms_type):
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال."""
if customer is None:
return True
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
if setting is None:
return True
return setting.is_enabled
def customer_toggle_map(customer):
"""فهرست نوع‌های پیامک با وضعیت مؤثر (برای نمایش در پنل مشتری)."""
overrides = {
s.sms_type: s.is_enabled
for s in CustomerSMSSetting.objects.filter(customer=customer)
}
return [
{"code": code, "label": label, "enabled": overrides.get(code, True)}
for code, label in SMSType.choices
]
+284
View File
@@ -0,0 +1,284 @@
"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
* **خط شروع (``scan_since``)**: فقط مواردی صف می‌شوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
آن‌ها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچ‌کدام از موارد قدیمی موجود
در دیتابیس یک‌باره ارسال نمی‌شوند.
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
اسکنرها فقط انواع «دوره‌ای» را پوشش می‌دهند (تأمین موجودی چک، سررسید نسیه، بازدید
دوره‌ای). انواع «رویدادی» (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از پرداخت) هنگام
وقوع رخداد توسط ویوها و از طریق ``admin_panel/sms/triggers.py`` صف می‌شوند.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید) کلیدشان شامل شمارهٔ دوره است. یعنی اگر مدیر یک مورد
را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره ساخته می‌شود؛ برای
همیشه خاموش نمی‌ماند.
"""
import logging
from datetime import datetime, time, timedelta
from django.utils import timezone
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from ..visit_policy import (as_aware, cycle_anchor_map, next_appointment_map,
visit_latest_due, visit_period)
from .enqueue import enqueue_sms
logger = logging.getLogger(__name__)
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).
در روزهای تغییر ساعت، نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
می‌شود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
# date → آغاز همان روز
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config, now=None):
self.since = config.scan_since
# مرجع زمانی «خط شروع»؛ برای اسکن با زمان تزریقی (تست/بازپخش) هم‌زمان می‌شود.
self.now = now
self.allow_backlog = bool(config.allow_backlog)
self.backlog_max = config.backlog_max or 0
self.max_per_run = config.max_per_run or 0
self.new_count = 0
self.backlog_count = 0
def is_backlog(self, trigger):
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریق‌شده.
"""
trigger = _aware(trigger)
since = self.since
if since is not None and self.now is not None:
moment = _aware(self.now)
now_ref = timezone.now()
if moment is not None and now_ref is not None:
# پنجره با «همان لحظه‌ای» که اسکن برایش اجرا می‌شود سنجیده شود،
# وگرنه اسکنِ بازپخش/تست همیشه همه‌چیز را «قدیمی» می‌بیند.
since = since + (moment - now_ref)
return since is not None and trigger < since
def can_send(self, trigger):
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
return False
if self.is_backlog(trigger):
return self.allow_backlog and self.backlog_count < self.backlog_max
return True
def record(self, trigger):
if self.is_backlog(trigger):
self.backlog_count += 1
else:
self.new_count += 1
def scan_cheque_funding(today):
"""یادآوری «تأمین موجودی چک» چند روز **پیش از** سررسید چکِ پرداخت‌نشده.
تعداد روزِ *قبل* از سررسید از قالب همین نوع (``offset_days`` در دیتابیس،
قابل ویرایش از پنل) خوانده می‌شود؛ مثلاً ``offset_days = 1`` یعنی یک روز پیش
از سررسید پیامک می‌رود تا مشتری فرصت تأمین موجودی داشته باشد.
پنجرهٔ یادآوری از «سررسید − offset روز» باز می‌شود و تا خودِ سررسید ادامه
دارد؛ پس اگر اسکنر چند روز از دست برود، یادآوری از دست نمی‌رود (چک‌هایی که
سررسیدشان رسیده هم واجد شرط می‌مانند). اگر چک پرداخت شود
(``payStatus=true``) دیگر یادآوری نمی‌گیرد؛ ``dedup_key`` نیز مانع ارسال
تکراری در اجراهای بعدی می‌شود.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
# پنجرهٔ یادآوری: از «سررسید − offset» تا «سررسید». یعنی فقط سررسیدهای
# امروز تا offset روز آینده واجد شرط‌اند و مقادیر دورتر هنوز پیامکی نمی‌گیرند.
# (سررسیدهای گذشته هم داخل شرط می‌مانند تا یک یادآوری از دست نرود.)
window_end = today + timedelta(days=max(offset, 0))
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__lte=window_end,
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
due = s.sarresidDT.date()
# لحظهٔ باز شدن پنجرهٔ یادآوری = سررسید − offset روز.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
# روزهای باقی‌مانده تا سررسید؛ اگر سررسید دورتر از پنجره باشد پیامک نمی‌رود.
days = (due - today).days
if days > offset:
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
"sayyad_id": s.check_sayyad_id, "owner_name": s.check_owner_name,
},
dedup_key=f"cheque_fund:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_nesiye_due(today):
template = _template(SMSType.NESIYE_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
qs = Sarresid.objects.filter(
type="nesiye", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.NESIYE_DUE,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days,
},
dedup_key=f"nesiye_due:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today, *, now=None):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
برای مشتریانی که هرگز بازدید نشده‌اند). مبنای «آخرین بازدید» و دوره، هر دو از
``admin_panel.visit_policy`` می‌آیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
نمی‌گیرد، چون قرار بعدی از قبل با او گذاشته شده است.
``now`` برای تست/بازپخش قابل تزریق است؛ پیش‌فرض «همین حالا» است.
اگر لحظهٔ واجد‌شرط‌شدن پیش از خط شروع باشد، مورد قدیمی تلقی می‌شود و فقط با
اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
template = _template(SMSType.VISIT)
if template is None:
return 0
period = visit_period()
now = timezone.now() if now is None else as_aware(now)
budget = _Budget(SMSProviderConfig.load(), now=now)
# لنگر چرخه: آخرین «بازدید» یا «نصب» (تصمیم محصولی). تماس تلفنی چرخه را ریست نمی‌کند.
anchors = cycle_anchor_map(now=now)
# قرار بعدی (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) ⇒ یادآوری لازم نیست.
appointments = next_appointment_map(now=now)
# مرز تاریخ‌ها باید به وقت **محلی** گرفته شود؛ `.date()` روی دیتاتایم aware،
# تاریخ UTC می‌دهد که در ساعت‌های اول شب تهران یک روز عقب‌تر است و باعث می‌شد
# مشتریِ دقیقاً روی مرز، از اسکن جا بیفتد.
cutoff_date = (now - period).astimezone(timezone.get_current_timezone()).date()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=cutoff_date,
).order_by("id")
count = 0
for customer in qs:
if customer.id in appointments:
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
anchor = anchors.get(customer.id) or _aware(customer.installDate)
if anchor is None:
continue
# «تازه‌ترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
# قاعدهٔ قبلی همیشه موعد اول را می‌سنجید و همین باعث می‌شد دوره‌های بعدی
# هرگز ساخته نشوند.
trigger = visit_latest_due(anchor, now=now)
# موعد یک «تاریخ» است نه یک لحظه؛ اگر روزِ موعد فرا رسیده باشد باید صف شود.
if trigger is None or trigger.date() > now.astimezone(
timezone.get_current_timezone()
).date():
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
# (قاعدهٔ قبلی — days // 274 — با موعد تقویمی هم‌تراز نبود و باعث
# گم‌شدنِ یک دوره و تکرار/پرش شمارهٔ چرخه می‌شد.)
due_month = trigger.strftime("%Y-%m")
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{due_month}",
)
if made:
budget.record(trigger)
count += made
return count
# فقط انواعی که «موعد» دارند و دوره‌ای اسکن می‌شوند؛ انواع رویدادی (خوش‌آمدگویی،
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
# توسط ویوها صف می‌شوند، نه این‌جا.
SCANNERS = [
("cheque_registration", scan_cheque_funding),
("nesiye_due", scan_nesiye_due),
("visit", scan_visit),
]
def run_all(today=None):
"""همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درج‌شده» را برمی‌گرداند."""
today = today or timezone.localdate()
results = {}
for name, func in SCANNERS:
try:
results[name] = func(today)
except Exception:
logger.exception("اسکن پیامک شکست خورد: %s", name)
results[name] = -1
return results
+107
View File
@@ -0,0 +1,107 @@
"""معنای «زمان‌بندی» برای هر نوع پیامک.
هر نوع پیامک زمان‌بندی متفاوتی دارد و نمی‌شود همه را زیر یک برچسب «روز قبل از سررسید»
جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالب‌ها بر اساسش فیلد درست را نشان
می‌دهد، هم اسکنر بر اساسش تصمیم می‌گیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت می‌شود
(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی می‌مانند.
"""
from ..models import SMSType
NONE = "none"
BEFORE_DUE = "before_due"
AFTER_DUE = "after_due"
PERIOD_MONTHS = "period_months"
# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
DEFAULT_VISIT_MONTHS = 9
TIMING = {
SMSType.WELCOME: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.SURVEY: {
"kind": NONE,
"field": None,
"note": "بی‌درنگ پس از ثبت فاکتور ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید چک",
"default": 1,
"note": "این تعداد روز پیش از سررسید چکِ پرداخت‌نشده، پیامک یادآوری "
"تأمین موجودی با مبلغ چک ارسال می‌شود.",
},
SMSType.VISIT: {
"kind": PERIOD_MONTHS,
"field": "repeat_interval_months",
"label": "هر چند ماه یک‌بار",
"default": DEFAULT_VISIT_MONTHS,
"note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.",
},
SMSType.VISIT_CREATED: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.INSTALL_CREATED: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت «اقدام از نوع نصب» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.PAYMENT_THANKS: {
"kind": NONE,
"field": None,
"note": "بی‌درنگ پس از ثبت پرداخت (سند وصول/بستانکار) ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 0,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
}
def timing_for(sms_type):
"""اطلاعات زمان‌بندی یک نوع پیامک (با پیش‌فرض بی‌خطر برای انواع ناشناخته)."""
return TIMING.get(sms_type, {"kind": NONE, "field": None, "note": ""})
def apply_timing(template, value):
"""فیلد زمانیِ متناسب با نوع قالب را می‌نشاند و بقیه را صفر/خالی می‌کند.
``value`` عدد صحیحِ واردشده از فرم است (یا ``None``). این کار تضمین می‌کند صرف‌نظر از
اینکه فرم چه بفرستد، فقط یک فیلد زمانی برای هر قالب معنا داشته باشد.
"""
info = timing_for(template.sms_type)
field = info["field"]
template.offset_days = 0
template.repeat_interval_days = None
template.repeat_interval_months = None
if field == "offset_days":
template.offset_days = value if value is not None else info["default"]
elif field == "repeat_interval_days":
template.repeat_interval_days = value if value else info["default"]
elif field == "repeat_interval_months":
template.repeat_interval_months = value if value else None
return template
def current_timing_value(template):
"""مقدار فعلی فیلد زمانیِ متناسب (برای نمایش در فرم).
اگر مقدار خالی باشد، پیش‌فرض همان نوع برگردانده می‌شود تا فیلدِ تنظیم‌نشده هم در
فرم دیده شود و مدیر بتواند مقدارش را تغییر دهد.
"""
info = timing_for(template.sms_type)
field = info["field"]
if field is None:
return None
value = getattr(template, field)
if value is None:
value = info.get("default")
return value
+100
View File
@@ -0,0 +1,100 @@
"""تریگرهای رویدادی پیامک (لایهٔ سرویس، بدون signal).
چهار نوع پیامک «رویدادی» هستند و لحظهٔ وقوع‌شان یک اکشن پنل است، نه یک سررسید
تاریخی؛ پس به‌جای اسکنر دوره‌ای، ویوها بعد از انجام کارِ اصلی این توابع را صدا
می‌زنند:
* :func:`notify_welcome` — ایجاد مشتری.
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
* :func:`notify_install_scheduled` — ثبت «اقدام از نوع نصب».
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
قرارداد پروژه: استفاده از signal ممنوع است؛ همه‌چیز صریح از دلِ ویو صدا زده می‌شود.
این توابع هیچ استثنایی به فراخوان پرتاب نمی‌کنند (``enqueue_sms`` خودش مقاوم است) تا
مسیر اصلی هرگز به‌خاطر پیامک مختل نشود.
"""
import logging
from ..models import SMSType
from .enqueue import enqueue_sms
logger = logging.getLogger(__name__)
# روش‌های «غیرپرداخت»: سند بستانکاری که به‌خاطر تخفیف صادر شده، پرداختِ واقعی نیست
# و نباید پیامک «تشکر از پرداخت» بگیرد.
NON_PAYMENT_METHODS = {"تخفیف"}
def notify_welcome(customer, *, created_by=None):
"""پیامک خوش‌آمدگویی در لحظهٔ ساخت مشتری."""
if customer is None or customer.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.WELCOME,
context={"name": customer.name_family},
dedup_key=f"welcome:{customer.pk}",
created_by=created_by,
)
def notify_factor_survey(customer, factor, *, created_by=None):
"""پیامک نظرسنجی، بی‌درنگ پس از ثبت فاکتور."""
if customer is None or factor is None or factor.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.SURVEY,
context={"name": customer.name_family, "amount": factor.amount},
dedup_key=f"survey:{factor.pk}",
created_by=created_by,
)
def notify_visit_scheduled(customer, action, *, created_by=None):
"""پیامک «بازدید شما برای تاریخ … ثبت شد» پس از ایجاد اقدام بازدید."""
if customer is None or action is None or action.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.VISIT_CREATED,
context={"name": customer.name_family, "date": action.actionDateTime},
dedup_key=f"visit_created:{action.pk}",
created_by=created_by,
)
def notify_install_scheduled(customer, action, *, created_by=None):
"""پیامک «نصب شما برای تاریخ … ثبت شد» پس از ایجاد اقدام نصب.
قرینهٔ :func:`notify_visit_scheduled` است و همان قرارداد را دارد: فقط برای
اقدام ``nasb`` صدا زده می‌شود، متن از قالب ``install_created`` می‌آید و
یکتاسازی با شناسهٔ همان رکورد اقدام انجام می‌شود (ویرایش دوباره = بدون تکرار).
"""
if customer is None or action is None or action.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.INSTALL_CREATED,
context={"name": customer.name_family, "date": action.actionDateTime},
dedup_key=f"install_created:{action.pk}",
created_by=created_by,
)
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
created_by=None):
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
``article_id`` برای یکتاسازی استفاده می‌شود تا یک پرداخت هرگز دو پیامک نگیرد.
``method`` نحوهٔ پرداخت است؛ اگر «تخفیف» باشد (که پرداختِ واقعی نیست) هیچ پیامکی
ساخته نمی‌شود.
"""
if customer is None:
return 0
if method and str(method).strip() in NON_PAYMENT_METHODS:
return 0
dedup_key = f"payment_thanks:{article_id}" if article_id is not None else None
return enqueue_sms(
customer=customer, sms_type=SMSType.PAYMENT_THANKS,
context={"name": customer.name_family, "amount": amount},
dedup_key=dedup_key,
created_by=created_by,
)
+1
View File
@@ -130,3 +130,4 @@ input[disabled]{
text-align: center;
border-left: 1px solid #b7b7b7;
}
@@ -0,0 +1,144 @@
{% load custom_tags %}
{% comment %}
کارت پروفایل مشتری — ستون سمت راست صفحهٔ مشتری.
تک‌منبع (DRY): هم customerBase.html و هم customerBaseNew.html این را include می‌کنند
تا ساختار، استایل و اسکریپت ویرایش/شماره‌ها در همهٔ صفحات مشتری یکسان بماند.
شناسه‌های موردنیاز جاوااسکریپت:
#formcsEdit, #enEdit, #editCs, #cancelEdit, #del_customer, #showUserDT, #userDT
{% endcomment %}
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">{{ customer.name_family }}</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
{% include "./_customer_phones.html" with phones_editable=1 %}
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input ss02 installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode ss02" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
<select name="laghv_bazdid" style="width: 100%" class="select" id="LaghvBazdid">
<option value="false" {% if not customer.laghv_bazdid %}selected{% endif %}>خیر</option>
<option value="true" {% if customer.laghv_bazdid %}selected{% endif %}>بله</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<span class="button is-light is-hidden" id="cancelEdit">انصراف</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
<script>
// اسکریپت مشترک کارت پروفایل مشتری: ویرایش/انصراف، افزودن و حذف شماره،
// باز/بستن در موبایل و حذف مشتری. (یک‌بار bind می‌شود.)
(function () {
if (window.__customerInfoCardInit) { return; }
window.__customerInfoCardInit = true;
// --- ورود به حالت ویرایش ---
$(document).on('click', '#enEdit', function () {
$('#formcsEdit').find('input, textarea, select').prop('disabled', false);
$('#formcsEdit').find('.remove-phone-btn, .add-phone-btn').prop('disabled', false);
$('#enEdit').addClass('is-hidden');
$('#cancelEdit').removeClass('is-hidden');
$('#editCs').removeClass('is-hidden');
});
// --- انصراف: بارگذاری مجدد؛ تغییرات ذخیره‌نشده دور ریخته می‌شود ---
$(document).on('click', '#cancelEdit', function () {
window.location.reload();
});
// --- افزودن شماره (در حالت ویرایش) ---
$(document).on('click', '.add-phone-btn', function () {
var phoneRow = `
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شمارهٔ تماس جدید">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
</div>
</div>`;
$('#dynamic-phones-edit').append(phoneRow);
});
// --- حذف شماره (حداقل یک شماره باید بماند) ---
$(document).on('click', '.remove-phone-btn', function () {
if ($('#dynamic-phones-edit .field.has-addons').length > 1) {
$(this).closest('.field.has-addons').remove();
} else {
window.alert('حداقل یک شمارهٔ تماس باید وجود داشته باشد.');
}
});
// --- باز/بستن مشخصات در موبایل ---
$(document).on('click', '#showUserDT', function () {
var $btn = $(this);
if ($btn.text().trim() === 'مشاهده مشخصات') {
$btn.text('مخفی کردن');
$('#userDT').removeClass('is-hidden-mobile');
} else {
$btn.text('مشاهده مشخصات');
$('#userDT').addClass('is-hidden-mobile');
}
});
// --- حذف مشتری ---
$(document).on('click', '#del_customer', function () {
if (window.confirm('با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف می‌شود، مطمئن هستید؟')) {
window.location.href = '/panel/customer/{{ customer.id }}/del';
}
});
})();
</script>
@@ -0,0 +1,94 @@
{% load custom_tags %}
{# ستون «شماره‌های تماس» مشتری — تک‌منبع برای همهٔ صفحاتِ ستون اطلاعات مشتری. #}
{# شماره‌ها از خودِ شیء customer استخراج می‌شوند (نه context view) تا هیچ صفحه‌ای بدون داده نماند. #}
{# با phones_editable=1 (قالب‌هایی که اسکریپت افزودن/حذف شماره دارند) دکمه‌های ویرایش هم می‌آیند. #}
{% customer_phones customer as sms_phone_entries %}
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-edit">
{% for entry in sms_phone_entries %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ entry.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="{{ entry.display }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
📞
</a>
</div>
{% if perms.admin_panel.ManageSMS and entry.is_mobile %}
<div class="control">
<button type="button"
class="button sms-primary-toggle {% if entry.is_primary %}is-warning{% else %}is-light{% endif %}"
data-url="{% url 'admin_panel:sms_primary_toggle' customer.id %}"
data-phone="{{ entry.raw }}"
data-primary="{% if entry.is_primary %}true{% else %}false{% endif %}"
title="{% if entry.is_primary %}برداشتن از شماره‌های اصلی پیامک{% else %}تعیین به‌عنوان شمارهٔ اصلی پیامک{% endif %}">{% if entry.is_primary %}★ اصلی{% else %}☆ اصلی{% endif %}</button>
</div>
{% endif %}
{% if phones_editable and perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% empty %}
<p class="help mt-2">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
</div>
{% if phones_editable and perms.admin_panel.ViewStore %}
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
+ افزودن شماره دیگر
</button>
{% endif %}
</div>
{% if perms.admin_panel.ManageSMS %}
<script>
// تعیین شمارهٔ اصلی پیامک از همین ستون (بدون فرم تودرتو) — با درخواست async و
// بارگذاری مجدد صفحه تا وضعیت شماره‌ها و رنگ دکمه‌ها به‌درستی به‌روز شوند.
(function () {
if (window.__smsPrimaryToggleInit) { return; }
window.__smsPrimaryToggleInit = true;
function csrfToken() {
var match = document.cookie.match(/(^|;\s*)csrftoken=([^;]+)/);
return match ? decodeURIComponent(match[2]) : '';
}
document.addEventListener('click', function (event) {
var btn = event.target.closest ? event.target.closest('.sms-primary-toggle') : null;
if (!btn || btn.disabled) { return; }
event.preventDefault();
var body = new URLSearchParams();
body.append('phone', btn.getAttribute('data-phone') || '');
body.append('is_primary', btn.getAttribute('data-primary') === 'true' ? 'false' : 'true');
btn.classList.add('is-loading');
btn.disabled = true;
fetch(btn.getAttribute('data-url'), {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRFToken': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: body.toString()
}).then(function (response) {
if (!response.ok) { throw new Error('HTTP ' + response.status); }
window.location.reload();
}).catch(function () {
btn.classList.remove('is-loading');
btn.disabled = false;
window.alert('خطا در تغییر شمارهٔ اصلی. لطفاً دوباره تلاش کنید.');
});
});
})();
</script>
{% endif %}
@@ -19,6 +19,7 @@
<script type="text/javascript" src="{% static 'admin_panel/persian-datepicker.min.js' %}"></script>
<link rel="stylesheet" type="text/css" href="{% static 'admin_panel/style.css' %}">
{% block cssEnd %}{% endblock %}
<style>
</style>
</head>
@@ -119,6 +120,30 @@
{% if perms.admin_panel.ManageSMS or perms.admin_panel.ManageSMSSettings %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
{% if perms.admin_panel.ManageSMSSettings %}
<a href="{% url 'admin_panel:sms_settings' %}" style="padding-right: 10px;" class="navbar-item">
تنظیمات سامانه
</a>
<a href="{% url 'admin_panel:sms_templates' %}" style="padding-right: 10px;" class="navbar-item">
قالب‌ها
</a>
{% endif %}
{% if perms.admin_panel.ManageSMS %}
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
<a href="{% url 'admin_panel:sms_report' %}" style="padding-right: 10px;" class="navbar-item">
گزارش ارسال
</a>
{% endif %}
</div>
</div>
{% endif %}
<a class="navbar-item">
کاربران
</a>
@@ -201,8 +226,21 @@
{% endif %}
</div>
{% if notice_text %}
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
<div class="notification {{ notice_kind }} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ notice_text }}</strong>
</div>
</div>
</div>
{% endif %}
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود؛ پیش‌تر بسته نمی‌شد و HTML نامعتبر می‌شد. #}
</div>
</body>
<script>
@@ -164,6 +164,8 @@
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود (همان باگ base.html). #}
</div>
</body>
<script>
@@ -4,7 +4,7 @@
{% block BodyCustomer1 %}
{% include 'admin_panel/customerActionInc.html' %}
{% include 'admin_panel/customerActionInc.html' with show_delete=True %}
<!--
<div id="action" class="column is-12-mobile is-12-desktop ">
@@ -62,9 +62,12 @@
{% endif %}
{% for action in actions %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4">
<div class="pr-4 pl-4 pb-4 pt-4 ax-cardrow">
<span class="ax-cardrow-main">
{% ifequal action.status 'finish' %}
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
{% endifequal %}
@@ -79,6 +82,27 @@
<span class="is-bazdid">{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}</span>
<span class="pr-3">{{ action.desc }}</span>
</span>
{% if show_delete %}
{% if action.nazarDT %}
<span class="ax-del is-locked" title="این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 0 1 6 0v3H9z"/></svg>
<span class="ax-del-text">نظرسنجی</span>
</span>
{% else %}
<form method="post" class="ax-del-form"
action="{% url 'admin_panel:customerActionDelete' action.customer.id action.id %}">
{% csrf_token %}
<button type="submit" class="ax-del is-danger"
title="حذف این اقدام"
data-type="{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}"
onclick="return confirm('این «' + (this.dataset.type || 'اقدام') + '» حذف شود؟\n\nاقدام از فهرست انتظار/وظایف خارج می‌شود و پیامک ارسال‌نشدهٔ مرتبط لغو می‌گردد. سابقه برای حسابرسی باقی می‌ماند.');">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2zm-3 6h12l-1 12a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9zm4 3v8h2v-8h-2zm4 0v8h2v-8h-2z"/></svg>
<span class="ax-del-text">حذف</span>
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
@@ -113,7 +137,9 @@
<p style="text-align: center; margin-bottom: 20px; color: #d90000; font-size: 18px;"> کارهای مانده از روزهای قبل</p>
{% for action in actionPr %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4">
{% ifequal action.status 'finish' %}
@@ -161,27 +187,222 @@
</div>
<style>
/* ===== دکمهٔ «حذف» روی کارت اقدام (mobile first) ===== */
.ax-cardrow {
display: flex; align-items: flex-start; justify-content: space-between;
gap: .5rem; flex-wrap: nowrap;
}
.ax-cardrow-main { flex: 1 1 auto; min-width: 0; }
.ax-del-form { margin: 0; flex: 0 0 auto; }
.ax-del {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .45rem; border-radius: .375rem; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: .68rem; font-weight: 700;
cursor: pointer; line-height: 1.5; white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease,
color .15s ease, box-shadow .15s ease;
}
.ax-del svg { width: 13px; height: 13px; fill: currentColor; flex: 0 0 auto; }
.ax-del:hover { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.ax-del:focus-visible {
outline: 2px solid #fca5a5; outline-offset: 1px;
}
.ax-del.is-danger { border-color: #fee2e2; color: #dc2626; background: #fff; }
.ax-del.is-danger:hover {
background: #dc2626; border-color: #dc2626; color: #fff;
box-shadow: 0 1px 6px rgba(220, 38, 38, .25);
}
/* «نظرسنجی دارد» ⇒ دکمه غیرفعال (قاعدهٔ محصولی) */
.ax-del.is-locked {
cursor: not-allowed; color: #9ca3af; background: #f9fafb;
border-color: #e5e7eb;
}
.ax-del.is-locked:hover { background: #f9fafb; border-color: #e5e7eb; color: #9ca3af; }
@media (max-width: 480px) {
.ax-del-text { display: none; } /* در موبایل فقط آیکون، برای صرفه‌جویی فضا */
.ax-del { padding: .25rem; }
}
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
همهٔ کلاس‌ها با پیشوند ax- هستند تا با سایر بخش‌ها تداخل نکنند. */
#ActionBox .ax-card {
width: calc(100% - 1rem); max-width: 44rem;
max-height: calc(100vh - 1rem); display: flex; flex-direction: column;
border-radius: .75rem; overflow: hidden;
}
#ActionBox .ax-head {
display: flex; align-items: center; justify-content: space-between;
gap: .5rem; padding: .75rem 1rem; flex: 0 0 auto;
background: #f8fafc; border-bottom: 1px solid #e5e7eb;
}
#ActionBox .ax-title { font-size: .95rem; font-weight: 800; color: #0f172a; margin: 0; }
#ActionBox .ax-sub { font-size: .75rem; color: #64748b; margin: .1rem 0 0; }
#ActionBox .ax-body {
padding: .75rem 1rem; overflow-y: auto; -webkit-overflow-scrolling: touch;
flex: 1 1 auto;
}
#ActionBox .ax-foot {
display: flex; gap: .5rem; justify-content: flex-start;
padding: .6rem 1rem; flex: 0 0 auto;
background: #f8fafc; border-top: 1px solid #e5e7eb;
}
#ActionBox .ax-foot .button { margin: 0 !important; min-height: 2.6rem; flex: 1 1 0; }
@media (min-width: 768px) {
#ActionBox .ax-foot { justify-content: flex-start; }
#ActionBox .ax-foot .button { flex: 0 0 auto; min-width: 7rem; }
}
/* بخش‌ها */
#ActionBox .ax-section { padding: .6rem 0; border-bottom: 1px dashed #eef2f6; }
#ActionBox .ax-section:last-of-type { border-bottom: 0; }
#ActionBox .ax-legend {
display: flex; align-items: center; gap: .4rem;
font-size: .78rem; font-weight: 800; color: #0e7490; margin: 0 0 .5rem;
}
#ActionBox .ax-step {
display: inline-flex; align-items: center; justify-content: center;
width: 1.35rem; height: 1.35rem; border-radius: 999px;
background: #33b5d3; color: #fff; font-size: .7rem; font-weight: 800;
}
/* شبکهٔ فیلدها: موبایل تک‌ستونه، دسکتاپ دوستونه */
#ActionBox .ax-grid { display: grid; grid-template-columns: 1fr; gap: .6rem; }
@media (min-width: 768px) {
#ActionBox .ax-grid { grid-template-columns: 1fr 1fr; }
#ActionBox .ax-span-2 { grid-column: 1 / -1; }
}
#ActionBox .ax-label {
font-size: .74rem !important; font-weight: 700; color: #475569; margin-bottom: .25rem;
}
#ActionBox .ax-help {
font-size: .7rem; color: #94a3b8; margin-top: .2rem; line-height: 1.5;
}
/* هدف لمسی بزرگ‌تر روی موبایل */
#ActionBox .input, #ActionBox .select select, #ActionBox .textarea {
font-size: .85rem;
}
@media (max-width: 767px) {
#ActionBox .input, #ActionBox .select select { min-height: 2.6rem; }
#ActionBox .textarea { min-height: 4.5rem; }
}
#ActionBox .select, #ActionBox .select select { width: 100%; }
/* ردیف تیک «انجام شده» */
#ActionBox .ax-check {
display: flex; align-items: center; gap: .5rem;
padding: .55rem .7rem; border: 1px solid #e5e7eb; border-radius: .5rem;
background: #fff; cursor: pointer; margin: 0;
}
#ActionBox .ax-check input { width: 1.05rem; height: 1.05rem; }
/* هشدار «آیا هماهنگی را انجام دادی؟» — فقط برای هماهنگیِ تیک‌خورده */
#ActionBox .ax-warn {
margin-top: .5rem; padding: .6rem .7rem;
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
color: #92400e; font-size: .78rem; line-height: 1.75;
}
#ActionBox .ax-warn .ax-warn-q { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
#ActionBox .ax-warn .ax-warn-hint { margin: 0; }
#ActionBox .ax-warn .ax-warn-actions {
display: none; gap: .4rem; margin-top: .55rem; flex-wrap: wrap;
}
#ActionBox .ax-warn .ax-warn-actions .button { margin: 0 !important; flex: 1 1 auto; }
@media (min-width: 768px) {
#ActionBox .ax-warn .ax-warn-actions .button { flex: 0 0 auto; }
}
/* وقتی ارسال متوقف شده تا کاربر تأیید کند */
#ActionBox .ax-warn.is-confirming {
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
}
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
/* اعلان «تاریخ قرار عقب‌تر» — کلاس مستقل تا به CSS پنل «انجام شد» وابسته نباشد */
#ActionBox .ax-holdback {
margin-top: .5rem; padding: .6rem .7rem;
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
color: #92400e; font-size: .78rem; line-height: 1.75;
}
#ActionBox .ax-holdback.is-blocking {
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
}
#ActionBox .ax-holdback .ax-hb-title { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
#ActionBox .ax-holdback .ax-hb-current { margin: 0 0 .4rem; }
#ActionBox .ax-holdback .ax-hb-current b { font-weight: 800; }
#ActionBox .ax-holdback .ax-hb-alt { margin: 0; }
#ActionBox .ax-holdback .ax-hb-check {
display: flex; align-items: flex-start; gap: .5rem; margin: 0;
padding: .5rem .6rem; border-radius: .45rem; background: #fff;
border: 1px solid #fbbf24; cursor: pointer; font-weight: 700; color: #7c2d12;
}
#ActionBox .ax-holdback .ax-hb-check input {
width: 1.05rem; height: 1.05rem; margin-top: .15rem; flex: 0 0 auto;
}
#ActionBox .ax-holdback:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
#ActionBox .ax-help-toggle {
display: flex; align-items: center; gap: .45rem; width: 100%;
padding: .55rem .7rem; border: 1px solid #bae6fd; border-radius: .5rem;
background: #f0f9ff; color: #0369a1; font-size: .78rem; font-weight: 700;
cursor: pointer; text-align: right; line-height: 1.4;
}
#ActionBox .ax-help-toggle:hover { background: #e0f2fe; }
#ActionBox .ax-help-toggle .ax-ico {
display: inline-flex; align-items: center; justify-content: center;
width: 1.25rem; height: 1.25rem; border-radius: 999px; flex: 0 0 auto;
background: #0284c7; color: #fff; font-size: .72rem; font-weight: 800;
}
#ActionBox .ax-help-toggle .ax-chev {
margin-inline-start: auto; transition: transform .15s ease; font-size: .8rem;
}
#ActionBox .ax-help-toggle[aria-expanded="true"] .ax-chev { transform: rotate(180deg); }
#ActionBox .ax-help-panel {
margin-top: .4rem; padding: .7rem .8rem; border: 1px solid #e0f2fe;
border-radius: .5rem; background: #fbfeff; font-size: .76rem; color: #334155;
line-height: 1.85;
}
#ActionBox .ax-help-panel h4 {
font-size: .78rem; font-weight: 800; color: #0f172a; margin: .6rem 0 .25rem;
}
#ActionBox .ax-help-panel h4:first-child { margin-top: 0; }
#ActionBox .ax-help-panel ul { margin: 0; padding-inline-start: 1.1rem; }
#ActionBox .ax-help-panel li { margin-bottom: .2rem; }
#ActionBox .ax-help-panel .ax-eg {
display: block; margin-top: .35rem; padding: .5rem .6rem;
background: #fff; border: 1px dashed #bae6fd; border-radius: .4rem;
}
</style>
{% if customer %}
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}"
data-customer-hold="{% if customer.visit_hold_until %}{{ customer.visit_hold_until|date:'c' }}{% endif %}">
{% else %}
<form id="ActionBox" class="modal" method="post" action="">
{% endif %}
{% csrf_token %}
{# تأیید صریح کاربر برای عقب‌بردن تاریخ قرار؛ فقط با زدن دکمهٔ تأیید پر می‌شود. #}
<input type="hidden" name="force_hold" id="forceHold" value="">
<div class="modal-background closeActionBox"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title is-hidden">ایجاد وظیفه</p>
<button type="button" class="delete closeActionBox" aria-label="close"></button>
<div class="modal-card ax-card">
<header class="modal-card-head ax-head">
<div>
<p class="ax-title">ایجاد وظیفه</p>
{% if customer %}<p class="ax-sub">{{ customer.name_family }}</p>{% endif %}
</div>
<button type="button" class="delete closeActionBox" aria-label="بستن"></button>
</header>
<section class="modal-card-body">
<section class="modal-card-body ax-body">
<div class="columns is-mobile is-multiline">
<div class="field column is-6-desktop is-12-mobile m-0">
<label class="label">نوع اقدام</label>
<div class="control">
<div class="select" style="width: 100%;">
<select id="actionType" required name="actionType" style="width: 100%;">
{# ---------- گام ۱: نوع اقدام و مسئول ---------- #}
<div class="ax-section">
<p class="ax-legend"><span class="ax-step">۱</span> نوع اقدام و مسئول</p>
<div class="ax-grid">
<div class="ax-field">
<label class="label ax-label" for="actionType">نوع اقدام</label>
<div class="select">
<select id="actionType" required name="actionType">
<option value="">انتخاب کنید</option>
<option value="bazdid">بازدید</option>
<option value="nasb">نصب</option>
@@ -192,29 +413,11 @@
</select>
</div>
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<label class="label">تاریخ و ساعتی که کار باید انجام شود</label>
<div class="control">
<input type="text" class="input dateTimePickerAnjam">
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<div class="control">
<label class="label mb-0">توضیحات برای یادآوری حین انجام کار:</label>
<p class="is-size-7">راجب کاری که در آینده میخواهید انجام دهید توضیحاتی را بنویسید</p>
<textarea id="desc" required name="desc" class="textarea" placeholder="مثلا: بامشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<label class="label">توسط</label>
<div class="control">
<div class="select" style="width: 100%;">
<select id="userID" required name="userID" style="width: 100%;">
<div class="ax-field">
<label class="label ax-label" for="userID">توسط</label>
<div class="select">
<select id="userID" required name="userID">
<option value="">انتخاب کنید</option>
{% if perms.admin_panel.ViewStore %}
{% for auser in allUsers %}
@@ -228,47 +431,199 @@
</div>
</div>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<div class="control">
<label class="checkbox">
<input name="is_done" id="checkIsDone" type="checkbox">
این کار انجام شده
{# ---------- گام ۲: زمان‌بندی ---------- #}
<div class="ax-section">
<p class="ax-legend"><span class="ax-step">۲</span> زمان‌بندی</p>
<div class="ax-grid">
<div class="ax-field">
<label class="label ax-label">تاریخ و ساعتی که کار باید انجام شود</label>
<input type="text" class="input dateTimePickerAnjam" autocomplete="off">
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
<p class="ax-help">تاریخ انجام این وظیفه (ثبت در «لیست وظایف» همان روز).</p>
</div>
{# فیلد «تا تاریخ پیگیری نشود» فقط برای «هماهنگی» #}
<div class="ax-field showWhenCoordination is-hidden">
<label class="label ax-label">تا تاریخ پیگیری نشود</label>
<input type="text" class="input dateTimePickerHoldUntil" autocomplete="off">
<input name="visitHoldUntil" id="visitHoldUntil" type="text" class="input is-hidden dateTimePickerHoldUntil-alt">
<p class="ax-help">تا پایان این روز، مشتری در «انتظار بازدید» دیده نمی‌شود. خالی = ۱۴ روز.</p>
</div>
</div>
{# اعلان «تاریخ عقب‌تر» — عرض کامل، بیرون از شبکهٔ دوستونه #}
<div class="ax-holdback is-hidden" id="holdBackwardNotice" tabindex="-1"
role="alert" aria-live="polite">
<p class="ax-hb-title">این تاریخ از قرار فعلی عقب‌تر است</p>
<p class="ax-hb-current">قرار فعلی: <b id="hbCurrent">—</b></p>
<label class="ax-hb-check is-hidden" id="hbForceRow" for="forceHoldCheck">
<input type="checkbox" id="forceHoldCheck">
<span>تاریخ قرار را عقب ببر</span>
</label>
<p class="ax-hb-alt is-hidden" id="hbCreateHint">
این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.
</p>
</div>
{# راهنمای بازشونده — فقط برای هماهنگی #}
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
aria-expanded="false" aria-controls="holdHelpPanel">
<span class="ax-ico" aria-hidden="true">!</span>
<span>راهنمای «هماهنگی» و «تا تاریخ پیگیری نشود»</span>
<span class="ax-chev" aria-hidden="true">▾</span>
</button>
<div class="ax-help-panel is-hidden" id="holdHelpPanel">
<h4>این تاریخ چه کاری می‌کند؟</h4>
<p>می‌گوید «تا این تاریخ با این مشتری تماس نگیرید». در این بازه، مشتری از فهرست
<span class="has-text-weight-bold">انتظار بازدید</span> پنهان می‌شود و روز بعد از آن
تاریخ <span class="has-text-weight-bold">خودکار</span> برمی‌گردد. تاریخ تا
<span class="has-text-weight-bold">پایان همان روز</span> معتبر است.</p>
<h4>مثال</h4>
<div class="ax-eg">
امروز ۱ آذر است. مشتری می‌گوید «سه هفتهٔ دیگر زنگ بزنید».<br>
نوع = <b>هماهنگی</b> ← تیک «این کار انجام شده» ← «تا تاریخ پیگیری نشود» = <b>۲۲ آذر</b>.<br>
نتیجه: از ۱ تا ۲۲ آذر مشتری در فهرست نیست؛ از ۲۳ آذر خودکار برمی‌گردد.
</div>
<h4>نکات مهم</h4>
<ul>
<li><b>خالی + تیک</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند.</li>
<li><b>فقط تمدید می‌کند، کوتاه نمی‌کند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
قرار به ۳۰ آذر می‌رود؛ تاریخ‌های عقب‌تر نادیده گرفته می‌شوند تا قرارِ دورتر از بین نرود.
اگر خودتان می‌خواهید قرار را عقب ببرید، وظیفه را <b>ویرایش</b> کنید و
گزینهٔ <b>«تاریخ قرار را عقب ببر»</b> را تیک بزنید
(در «ایجاد وظیفهٔ تازه» این گزینه وجود ندارد).</li>
<li><b>حذف یا لغو هماهنگی</b> ← قرار بی‌اثر می‌شود و مشتری بلافاصله برمی‌گردد.</li>
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد می‌شود.</li>
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمی‌شود</b>؛
تاریخ آخرین بازدید و دورهٔ ۹ ماههٔ پیامک دست نمی‌خورد.
برای اعزام پرسنل از اقدام نوع <b>بازدید</b> استفاده کنید.</li>
<li>در مدت تعویق، <b>پیامک یادآوری ۹ ماههٔ بازدید</b> ارسال نمی‌شود.</li>
</ul>
</div>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
<label class="label">تاریخ و ساعت اتمام کار</label>
<div class="control">
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish">
{# ---------- گام ۳: توضیحات ---------- #}
<div class="ax-section">
<p class="ax-legend"><span class="ax-step">۳</span> توضیحات و وضعیت</p>
<div class="ax-grid">
<div class="ax-field ax-span-2">
<label class="label ax-label" for="desc">توضیحات برای یادآوری حین انجام کار</label>
<textarea id="desc" required name="desc" class="textarea"
placeholder="مثلا: با مشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
</div>
<div class="ax-field ax-span-2">
<label class="ax-check">
<input name="is_done" id="checkIsDone" type="checkbox">
<span>این کار انجام شده است</span>
</label>
{# هشدار/تأیید: فقط برای «هماهنگی» با تیکِ «انجام شده» #}
<div class="ax-warn is-hidden" id="holdDoneWarning" tabindex="-1"
role="alert" aria-live="polite">
<p class="ax-warn-q">آیا مطمئن هستی که هماهنگی را انجام دادی؟</p>
<p class="ax-warn-hint">اگر هنوز هماهنگی نکردی، این تیک را بردار.</p>
<div class="ax-warn-actions">
<button type="button" id="holdConfirmYes" class="button is-warning is-small">
بله، هماهنگی انجام شد
</button>
<button type="button" id="holdConfirmNo" class="button is-light is-small">
نه، تیک را بردار
</button>
</div>
</div>
</div>
<div class="ax-field showWhenDone is-hidden">
<label class="label ax-label" for="FinishDateTime">تاریخ و ساعت اتمام کار</label>
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish" autocomplete="off">
<input name="FinishDateTime" type="text" class="input dateTimePickerFinish-alt is-hidden">
<p class="ax-help">خالی بماند، زمان ثبت در نظر گرفته می‌شود.</p>
</div>
<div class="ax-field showWhenDone is-hidden">
<label class="label ax-label" for="actionResult">نتیجه</label>
<textarea name="result" id="actionResult" class="textarea"
placeholder="نتیجهٔ کار یا پیگیری انجام‌شده را بنویسید"></textarea>
</div>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
<div class="control">
<label class="label mb-0">نتیجه</label>
<p class="is-size-7">نتیجه کار یا پیگیری که انجام داده اید را بنویسید</p>
<textarea name="result" id="actionResult" class="textarea" placeholder="نتیجه فعالیت انجام شده را بنویسید"></textarea>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button type="button" class="button closeActionBox">لغو</button>
<footer class="modal-card-foot ax-foot">
<button type="button" class="button closeActionBox">انصراف</button>
<input id="sabtBtn05" type="submit" class="button is-success" value="ثبت"/>
</footer>
</div>
</form>
<script>
// وضعیت مودال:
// isEditMode ⇒ در حال ویرایش یک اقدام موجود (نه ایجاد)
// holdAutoChecked ⇒ تیک «انجام شده» را خودِ سیستم برای هماهنگی زده است
// holdConfirmed ⇒ کاربر تأیید کرده که هماهنگی انجام شده است
var isEditMode = false;
var holdAutoChecked = false;
var holdConfirmed = false;
// لایهٔ دوم محافظت: فرم «حذف» داخل کارتِ قابل‌کلیک است؛ رویداد را همان‌جا
// متوقف می‌کنیم تا به هندلرِ بازکردن مودالِ ویرایش نرسد.
// بایند «مستقیم» (نه delegated) چون این بلوک با هر ردیف تکرار می‌شود؛
// نام‌فضا (axdel) + off مانع از انباشته‌شدن هندلرها روی ردیف‌های تکراری می‌شود.
$('.ax-del-form, .ax-del')
.off('.axdel')
.on('click.axdel mousedown.axdel', function (e) {
e.stopPropagation();
});
// پیکر «تا تاریخ پیگیری نشود» را ریست می‌کند.
// نکته: pDatepicker یک Model برمی‌گرداند؛ اگر سلکتور هیچ المانی پیدا نکند
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
// TypeError می‌شد و بقیهٔ منطقِ هندلر (پنهان‌کردن فیلدها، برداشتن تیک خودکار)
// اجرا نمی‌شد.
// پرکردن پیکر «تا تاریخ پیگیری نشود» از مقدار ذخیره‌شدهٔ مشتری.
// بدون این، هنگام بازکردن مجدد یک اقدام، فیلد همیشه خالی دیده می‌شد
// (مقدار در دیتابیس بود ولی مودال آن را بارگذاری نمی‌کرد).
function seedHoldPicker(isoValue) {
var box = $("#visitHoldUntil");
if (!isoValue) {
resetHoldPicker();
return;
}
var ms = new Date(isoValue).getTime();
if (isNaN(ms)) {
resetHoldPicker();
return;
}
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate(ms);
}
box.val(String(ms));
}
function resetHoldPicker() {
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate();
}
$("#visitHoldUntil").val('');
}
$(".closeActionBox").click(function(){
$("#ActionBox").removeClass("is-active");
});
$("#showActionBox").click(function(){
isEditMode = false;
holdAutoChecked = false;
holdConfirmed = false;
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
$("#ActionBox").addClass("is-active");
{% if customer %}
var NewActionUrl = "{% url 'admin_panel:customerNewAction' customer.id %}";
@@ -282,22 +637,190 @@
$("#actionResult").val('');
dateTimePickerAnjam.setDate();
dateTimePickerFinish.setDate();
resetHoldPicker();
syncHoldBackward();
$(".showWhenCoordination").addClass("is-hidden");
closeHoldHelp();
$("#sabtBtn05").val("ثبت");
});
$("#checkIsDone").change(function() {
if(this.checked) {
$(".showWhenDone").removeClass("is-hidden");
} else {
$(".showWhenDone").addClass("is-hidden");
// نمایش/پنهان‌کردن فیلدهای «انجام شده».
function syncDoneFields() {
$(".showWhenDone").toggleClass("is-hidden", !$("#checkIsDone").prop("checked"));
}
// هشدار «آیا هماهنگی را انجام دادی؟» فقط برای هماهنگیِ تیک‌خورده نمایش داده می‌شود.
function syncHoldWarning() {
var active = ($("#actionType").val() === 'hamahangi') && $("#checkIsDone").prop("checked");
$("#holdDoneWarning").toggleClass("is-hidden", !active);
if (!active) {
$("#holdDoneWarning").removeClass("is-confirming");
holdConfirmed = false;
}
}
$("#checkIsDone").change(function() {
// تغییر دستی کاربر ⇒ دیگر «تیک خودکار» نیست.
holdAutoChecked = false;
holdConfirmed = false;
syncDoneFields();
syncHoldWarning();
});
$(".actionBoxOpenB").click(function(){
// تأیید یا ردِ هشدار در لحظهٔ ثبت
function showHoldConfirm() {
var box = $("#holdDoneWarning");
box.removeClass("is-hidden").addClass("is-confirming");
if (box[0].scrollIntoView) {
box[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
box.trigger('focus');
}
$("#holdConfirmYes").click(function() {
holdConfirmed = true;
// submit مستقیم روی المان فرم؛ اعتبارسنجی HTML5 در تلاش اول انجام شده است.
$("#ActionBox")[0].submit();
});
$("#holdConfirmNo").click(function() {
holdConfirmed = false;
holdAutoChecked = false;
$("#checkIsDone").prop("checked", false);
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
syncDoneFields();
$("#checkIsDone").trigger('focus');
});
// ══════════ عقب‌بردن تاریخ قرار ══════════
// قاعدهٔ سیستم: تاریخ «تا تاریخ پیگیری نشود» فقط رو به جلو می‌رود تا قرارِ
// دورتری که ثبت شده از بین نرود. در «ویرایش» اگر کاربر خودش بخواهد عقب ببرد،
// قرار فعلی را می‌بیند و با تیک زدن اجازهٔ صریح می‌دهد. در «ایجادِ» وظیفهٔ تازه
// این اجازه داده نمی‌شود؛ چون کاربر قصد اصلاح قرارِ موجود را ندارد و ممکن است
// ناخواسته قولِ ثبت‌شدهٔ قبلی را کوتاه کند.
function holdCurrentMs() {
var iso = ($("#ActionBox").attr('data-customer-hold') || '').trim();
if (!iso) { return null; }
var ms = new Date(iso).getTime();
return isNaN(ms) ? null : ms;
}
// مقدار پیکر در فیلد مخفی به‌صورت timestamp میلی‌ثانیه‌ای است؛ اگر پیکر
// تاریخ شمسی متنی گذاشت، همان را هم می‌پذیریم.
function holdTargetMs() {
var raw = ($("#visitHoldUntil").val() || '').trim();
if (!raw) { return null; }
if (/^\d+$/.test(raw)) {
var n = parseInt(raw, 10);
return isNaN(n) ? null : n;
}
var parsed = Date.parse(raw.replace(/\//g, '-'));
return isNaN(parsed) ? null : parsed;
}
function sameLocalDay(a, b) {
var da = new Date(a), db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
}
function jalaliDay(ms) {
if (ms === null) { return '—'; }
try {
if (typeof persianDate !== 'undefined') {
return new persianDate(ms).format('YYYY/MM/DD');
}
} catch (err) { /* کتابخانه در دسترس نیست ⇒ تاریخ میلادی */ }
var d = new Date(ms);
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
}
// آیا تاریخ انتخابی از قرار فعلی عقب‌تر است؟ (مستقل از تیک کاربر)
function holdIsBackward() {
if ($("#actionType").val() !== 'hamahangi') { return false; }
var current = holdCurrentMs(), target = holdTargetMs();
if (current === null || target === null) { return false; }
if (sameLocalDay(current, target)) { return false; }
return target < current;
}
// به‌روزرسانی اعلان (و اجازهٔ عقب‌بردن فقط در ویرایش) — با هر تغییر تاریخ/نوع.
function syncHoldBackward() {
var notice = $("#holdBackwardNotice");
var needed = holdIsBackward();
var canForce = needed && isEditMode;
// تیک فقط وقتی معنا دارد که عقب‌بردن مجاز باشد؛ در غیر این صورت پاک می‌شود.
$("#hbForceRow").toggleClass("is-hidden", !canForce);
$("#hbCreateHint").toggleClass("is-hidden", !needed || canForce);
if (!canForce) {
$("#forceHoldCheck").prop('checked', false);
$("#forceHold").val('');
}
if (!needed) {
notice.addClass("is-hidden").removeClass("is-blocking");
return;
}
$("#hbCurrent").text(jalaliDay(holdCurrentMs()));
notice.removeClass("is-hidden");
}
// تیک «عقب ببر» ⇒ فیلد مخفی force_hold برای سرور.
$("#forceHoldCheck").on('change', function() {
var checked = $(this).prop('checked');
$("#forceHold").val(checked ? '1' : '');
$("#holdBackwardNotice").toggleClass("is-blocking", !checked);
});
// هر تغییر تاریخ/نوع اقدام ⇒ وضعیت دوباره محاسبه می‌شود.
$("#ActionBox").on('change input', "#visitHoldUntil, #actionType",
function() { syncHoldBackward(); });
// پیکر تاریخ هم بعد از انتخاب، فیلد مخفی را پر می‌کند.
$(document).on('change', ".dateTimePickerHoldUntil, .dateTimePickerHoldUntil-alt",
function() { syncHoldBackward(); });
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجام‌شده» (فقط در حالت ایجاد، نه ویرایش).
$("#ActionBox").on('submit', function(event) {
// (۱) در ویرایش: تاریخ عقب‌تر از قرار فعلی ⇒ تا تیک تأیید نزده، ارسال نمی‌شود.
// در «ایجاد»، وظیفه ذخیره می‌شود و فقط تاریخ قرار عقب نمی‌رود؛ سرور
// همین را با پیام روشن به کاربر می‌گوید، پس ارسال بلاک نمی‌شود.
if (isEditMode && holdIsBackward() && !$("#forceHoldCheck").prop('checked')) {
event.preventDefault();
var notice = $("#holdBackwardNotice").removeClass("is-hidden").addClass("is-blocking");
if (notice[0].scrollIntoView) {
notice[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
notice.trigger('focus');
return;
}
// (۲) هماهنگیِ «انجام‌شده»: تأیید یک‌بارهٔ انجام کار.
if (isEditMode || holdConfirmed) {
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
}
if ($("#actionType").val() !== 'hamahangi') {
return; // سایر انواع اقدام دست‌نخورده
}
if (!$("#checkIsDone").prop("checked")) {
return; // هماهنگیِ انجام‌نشده ⇒ بدون تعویق
}
event.preventDefault();
showHoldConfirm();
});
$(".actionBoxOpenB").click(function(event){
// کلیک روی کنترل‌های «حذف» نباید مودال ویرایش را باز کند.
// (دکمه داخل کارت است و رویداد به والد بالا می‌رفت.)
if ($(event.target).closest('.ax-del-form, .ax-del').length) {
return;
}
isEditMode = true;
$("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
var action_id = $(this).parent().attr("action_id");
var customer_id = $(this).parent().attr("customer_id");
var row = $(this).parent();
var action_id = row.attr("action_id");
var customer_id = row.attr("customer_id");
var customerHold = row.attr("customer_hold") || "";
actionEditUrl = actionEditUrl.replace('1111', customer_id);
actionEditUrl = actionEditUrl.replace('2222', action_id);
$("#ActionBox").prop("action", actionEditUrl);
@@ -316,6 +839,16 @@
}
$("#ActionBox").addClass("is-active");
$("#actionType").val(ActionParseData[0]['fields']['actionType']);
// در ویرایش، تیکِ خودکار و تأیید نباید درگیر شوند.
holdAutoChecked = false;
holdConfirmed = false;
// فیلد «تا تاریخ پیگیری نشود» با نوع اقدام هم‌راستا شود (در حالت ویرایش هم).
closeHoldHelp();
toggleCoordinationField(ActionParseData[0]['fields']['actionType']);
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
syncHoldBackward();
$("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){
@@ -326,6 +859,8 @@
$(".showWhenDone").addClass("is-hidden");
}
$("#actionResult").val(ActionParseData[0]['fields']['result']);
// هشدار «هماهنگی انجام شد» در ویرایش هم صرفاً نمایش داده می‌شود (بدون تأیید).
syncHoldWarning();
});
});
@@ -347,6 +882,79 @@
},
});
var dateTimePickerHoldUntil = $(".dateTimePickerHoldUntil").pDatepicker({
altField: '.dateTimePickerHoldUntil-alt',
initialValue: false,
format: 'YYYY/MM/DD',
calendar:{
persian: {
locale: 'fa'
}
},
timePicker: {
enabled: false,
second:{
enabled: false,
}
},
// بعد از انتخاب تاریخ، وضعیت «عقب‌تر بودن» فوراً بازمحاسبه شود
// (پیکر مقدار altField را برنامه‌نویسی‌شده می‌نویسد و ممکن است change رخ ندهد).
onSelect: function () {
setTimeout(function () { syncHoldBackward(); }, 0);
},
});
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
// این کار جلوی خطاهای خاموش مثل «تقویم باز نمی‌شود» را می‌گیرد.
if (!dateTimePickerHoldUntil) {
console.warn('[ActionBox] pDatepicker برای «تا تاریخ پیگیری نشود» ساخته نشد؛ ' +
'کلاس .dateTimePickerHoldUntil را در قالب بررسی کنید.');
}
// فیلد «تا تاریخ پیگیری نشود» فقط برای اقدام «هماهنگی» نمایش داده می‌شود.
function toggleCoordinationField(actionType) {
var isCoordination = (actionType === 'hamahangi');
$(".showWhenCoordination").toggleClass("is-hidden", !isCoordination);
if (!isCoordination) {
resetHoldPicker();
closeHoldHelp(); // راهنما هم بسته شود تا در اقدام بعدی باز نماند
}
}
// ---- راهنمای بازشوندهٔ «هماهنگی» ----
function closeHoldHelp() {
$("#holdHelpPanel").addClass("is-hidden");
$("#holdHelpToggle").attr("aria-expanded", "false");
}
$("#holdHelpToggle").click(function(){
var expanded = $(this).attr("aria-expanded") === "true";
$("#holdHelpPanel").toggleClass("is-hidden", expanded);
$(this).attr("aria-expanded", expanded ? "false" : "true");
});
$("select#actionType").on('change', function(){
var type = $(this).val();
toggleCoordinationField(type);
if (type === 'hamahangi') {
// پیش‌فرض: «هماهنگی» یعنی همین حالا تماس گرفته شد ⇒ تیک «انجام شده».
// هشدار و تأیید، جلوی ثبتِ ناخواسته را می‌گیرد.
if (!$("#checkIsDone").prop("checked")) {
$("#checkIsDone").prop("checked", true);
holdAutoChecked = true;
}
} else if (holdAutoChecked) {
// تیک را خودمان زده بودیم ⇒ با تغییر نوع برمی‌داریم (به‌ویژه برای «بازدید»).
$("#checkIsDone").prop("checked", false);
holdAutoChecked = false;
}
holdConfirmed = false;
syncDoneFields();
syncHoldWarning();
});
var dateTimePickerAnjam = $(".dateTimePickerAnjam").pDatepicker({
altField: '.dateTimePickerAnjam-alt',
initialValue: true,
@@ -6,74 +6,7 @@
<div class="">
<div class="columns m-0">
<div class="column is-12-mobile is-4-desktop">
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">محمد جواد فصاحت</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-9-desktop is-9-mobile m-0">
<label class="label">تلفن همراه</label>
<div class="control">
{% if perms.admin_panel.ViewStore %}
<input disabled value="{{ customer.phone1 }}" name="phone1" id="phone-1" class="input an_phone" type="text" placeholder="تلفن 1">
<input disabled value="{{ customer.phone2 }}" name="phone2" id="phone-2" class="input an_phone" type="text" placeholder="تلفن 2">
<input disabled value="{{ customer.phone3 }}" name="phone3" id="phone-3" class="input an_phone" type="text" placeholder="تلفن 3">
{% else %}
<p class="pb-4">
<a href="tel:{% if customer.phone1|first in '0' %}{{ customer.phone1 }}{% else %}0{{ customer.phone1 }}{% endif %}">{{ customer.phone1 }}</a>
</p>
<p class="pb-4">
<a href="tel:0{% if customer.phone2|first in '0' %}{{ customer.phone2 }}{% else %}0{{ customer.phone2 }}{% endif %}">{{ customer.phone2 }}</a>
</p>
<p class="pb-4">
<a href="tel:0{% if customer.phone3|first in '0' %}{{ customer.phone3 }}{% else %}0{{ customer.phone3 }}{% endif %}">{{ customer.phone3 }}</a>
</p>
{% endif %}
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
{% include "./_customer_info.html" %}
</div>
<div class="column is-12-mobile is-8-desktop">
@@ -248,11 +181,6 @@
});
$("#del_customer").click(function(){
if(confirm("با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف میشود، مطمئن هستید؟")){
window.location.href = "/panel/customer/{{ customer.id }}/del";
}
});
$(".closeNewIteam").click(function(){
$("#addNewIteam").removeClass("is-active");
@@ -337,22 +265,6 @@ $(document).ready(function() {
var test = 0
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#enEdit").addClass("is-hidden");
$("#editCs").removeClass("is-hidden");
});
$("#showUserDT").click(function(){
if( $(this).html()=='مشاهده مشخصات' ){
$(this).html('مخفی کردن');
$("#userDT").removeClass("is-hidden-mobile");
}else{
$(this).html('مشاهده مشخصات');
$("#userDT").addClass("is-hidden-mobile");
}
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
@@ -7,7 +7,7 @@
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
{% for message in messages %}
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'warning' %}is-warning{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ message }}</strong>
</div>
@@ -18,158 +18,7 @@
<div class="">
<div class="columns m-0">
<div class="column is-12-mobile is-4-desktop">
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">
{{ customer.name_family }}
</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-edit">
{% if customer.phones.all %}
{% for phone_obj in customer.phones.all %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ phone_obj.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ phone_obj.phone_number }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ phone_obj.phone_number }}" class="button is-success" title="تماس با {{ phone_obj.phone_number }}">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
{% if customer.phone1 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 1" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone1 }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ customer.phone1 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone2 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 2" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone2 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone2 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone3 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 3" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone3 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone3 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
{% if perms.admin_panel.ViewStore %}
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
+ افزودن شماره دیگر
</button>
{% endif %}
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input ss02 installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode ss02 " type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
<select name="laghv_bazdid" style="width: 100%" class="select" id="LaghvBazdid">
<option value="false">خیر</option>
<option value="true">بله</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
{% include "./_customer_info.html" %}
</div>
<div class="column is-12-mobile is-8-desktop">
@@ -184,6 +33,7 @@
<li id="newFactorOpen" class="{% if url_name == 'customerNew' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerNew' customer.id %}">ایجاد فاکتور</a></li>
<li id="FactorHaOpen" class="{% if url_name == 'customerFactors' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerFactors' customer.id %}">فاکتورها </a></li>
<li class=""><a href="{% url 'admin_panel:customerAction' customer.id %}">بازاریابی</a></li>
{% if perms.admin_panel.ManageSMS %}<li class="{% if url_name == 'customerSmsHistory' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerSmsHistory' customer.id %}">سوابق پیامک</a></li>{% endif %}
{% endwith %}
@@ -555,11 +405,6 @@
});
$("#del_customer").click(function(){
if(confirm("با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف میشود، مطمئن هستید؟")){
window.location.href = "/panel/customer/{{ customer.id }}/del";
}
});
$(".closeNewIteam").click(function(){
$("#addNewIteam").removeClass("is-active");
@@ -685,50 +530,6 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
$("#enEdit").addClass("is-hidden");
$("#editCs").removeClass("is-hidden");
});
// توابع داینامیک فرم ویرایش
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس جدید">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
</div>
</div>`;
$("#dynamic-phones-edit").append(phoneRow);
});
$(document).on('click', '.remove-phone-btn', function() {
if ($("#dynamic-phones-edit .has-addons").length > 1) {
$(this).closest('.field.has-addons').remove();
} else {
alert("حداقل یک شماره تماس باید وجود داشته باشد.");
}
});
$("#showUserDT").click(function(){
if( $(this).html()=='مشاهده مشخصات' ){
$(this).html('مخفی کردن');
$("#userDT").removeClass("is-hidden-mobile");
}else{
$(this).html('مشاهده مشخصات');
$("#userDT").addClass("is-hidden-mobile");
}
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
@@ -181,6 +181,11 @@
</div>
</div>
{% comment %}
باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
(/panel/customer/<id>/sms/) منتقل شده تا فرم فاکتور تمام‌عرض بماند و سوابق و
تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
{% endcomment %}
{% endblock %}
@@ -0,0 +1,587 @@
{% extends "./customerBaseNew.html" %}
{% load custom_tags %}
{% load static %}
{% block BodyCustomer1 %}
{% comment %}
صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمی‌نشیند؛ فقط آمار + دکمه‌ها بالاست و
تنظیمات/ارسال سفارشی/شماره‌های اصلی داخل مودال باز می‌شوند.
همهٔ کلاس‌ها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
صفحات دیگر نشت نکنند.
{% endcomment %}
<style>
{% verbatim %}
#customerSmsHistory, #customerSmsHistory *, #customerSmsHistory *::before,
#customerSmsHistory *::after { box-sizing: border-box; }
/* متغیرها روی خودِ صفحه و روی ریشهٔ مودال‌ها تعریف می‌شوند؛ مودال‌ها در بلوک
جدا (BodyCustomer2) رندر می‌شوند و خارج از #customerSmsHistory هستند. */
#customerSmsHistory, .csmsx-modal { --cs-line: #e8ecf3; --cs-ink: #363636;
--cs-muted: #7a7a7a; --cs-brand: #3273dc; --cs-ok: #48c774; }
/* ---------- نوار بالای صفحه ---------- */
.csmsx-head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;
justify-content: space-between; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
border: 1px solid var(--cs-line); border-radius: 14px;
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fd 100%); }
.csmsx-head__main { min-width: 0; }
.csmsx-title { font-size: 1.15rem; font-weight: 700; color: var(--cs-ink);
margin: 0 0 .25rem; overflow-wrap: anywhere; }
.csmsx-sub { margin: 0; color: var(--cs-muted); font-size: .82rem; line-height: 1.8; }
.csmsx-sub b { color: var(--cs-ink); }
.csmsx-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; }
.csmsx-stat { display: inline-flex; align-items: center; gap: .35rem;
font-size: .78rem; padding: .25rem .65rem; border-radius: 999px;
background: #fff; border: 1px solid var(--cs-line); color: #4a4a4a; white-space: nowrap; }
.csmsx-stat b { font-variant-numeric: tabular-nums; }
.csmsx-stat.is-sent { border-color: #c8ebd5; background: #f2fbf6; }
.csmsx-stat.is-pending { border-color: #ffeaa7; background: #fffdf3; }
.csmsx-stat.is-failed { border-color: #ffd0d9; background: #fff5f7; }
.csmsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
{% endverbatim %}
</style>
<style>
{% verbatim %}
/* ---------- نوار فیلترها ---------- */
.csmsx-filters { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end;
padding: .85rem 1rem; margin-bottom: 1rem; background: #fff;
border: 1px solid var(--cs-line); border-radius: 12px; }
.csmsx-filters .control { margin: 0; }
.csmsx-filters label { display: block; font-size: .75rem; color: var(--cs-muted);
margin-bottom: .25rem; }
.csmsx-filters select { min-width: 10.5rem; }
.csmsx-filters__note { margin-inline-start: auto; font-size: .78rem;
color: var(--cs-muted); }
/* ---------- فهرست سوابق ---------- */
.csmsx-history { background: #fff; border: 1px solid var(--cs-line);
border-radius: 14px; overflow: hidden; }
.csmsx-history__scroll { overflow-x: auto; }
.csmsx-history table { margin: 0 !important; background: transparent; }
.csmsx-history thead th { position: sticky; top: 0; z-index: 1; background: #f7f9fc;
color: #4a4a4a; font-size: .78rem; font-weight: 700;
border-bottom: 1px solid var(--cs-line) !important; }
.csmsx-history tbody tr:hover { background: #fafcff; }
.csmsx-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 34rem;
line-height: 1.95; font-size: .84rem; }
/* پیامک‌های بلند (متن قالب‌ها چندخطی است) کل جدول را بی‌اندازه بلند می‌کردند؛
متن در ۴ خط خلاصه می‌شود و با «نمایش کامل» باز می‌شود. */
.csmsx-msg.is-clamped { display: -webkit-box; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; overflow: hidden; }
.csmsx-expand { margin-top: .25rem; font-size: .72rem; padding: .1rem .45rem;
border-radius: 6px; border: 1px solid var(--cs-line); background: #fff;
color: var(--cs-brand); cursor: pointer; }
.csmsx-expand:hover { background: #f4f8ff; }
.csmsx-meta { font-size: .76rem; line-height: 1.7; color: var(--cs-muted); }
.csmsx-badge { white-space: nowrap; }
.csmsx-type-chip { display: inline-block; font-size: .75rem; padding: .1rem .5rem;
border-radius: 999px; background: #f1f5fb; color: #33507d; white-space: nowrap; }
{% endverbatim %}
</style>
<style>
{% verbatim %}
/* ---------- مودال‌ها ---------- */
.csmsx-modal .modal-content { max-width: 46rem; width: calc(100% - 1.5rem); }
.csmsx-modal .box { padding: 0; overflow: hidden; border-radius: 14px; }
.csmsx-modal__head { display: flex; align-items: flex-start; justify-content: space-between;
gap: .75rem; padding: 1rem 1.15rem; border-bottom: 1px solid var(--cs-line);
background: linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%); }
.csmsx-modal__title { margin: 0; font-size: 1.02rem; font-weight: 700; color: var(--cs-ink); }
.csmsx-modal__sub { margin: .2rem 0 0; font-size: .78rem; color: var(--cs-muted); line-height: 1.8; }
.csmsx-modal__body { padding: 1rem 1.15rem; max-height: min(68vh, 34rem); overflow-y: auto; }
.csmsx-modal__foot { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: flex-end;
padding: .85rem 1.15rem; border-top: 1px solid var(--cs-line); background: #fcfcfd; }
.csmsx-section-title { display: flex; align-items: center; gap: .45rem; margin: 0 0 .6rem;
font-size: .82rem; font-weight: 700; color: #4a4a4a; }
.csmsx-section + .csmsx-section { margin-top: 1.15rem; padding-top: 1.15rem;
border-top: 1px dashed var(--cs-line); }
/* ردیف هر نوع پیامک داخل مودال تنظیمات */
.csmsx-type { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
gap: .6rem .75rem; align-items: center; padding: .6rem .7rem; border-radius: 10px; }
.csmsx-type + .csmsx-type { margin-top: .3rem; }
.csmsx-type:hover { background: #fafcff; }
.csmsx-type.is-off { background: #fcfcfc; }
.csmsx-type.is-off .csmsx-type__label { color: #9a9a9a; }
.csmsx-type__icon { font-size: 1.15rem; line-height: 1; }
.csmsx-type__main { min-width: 0; }
.csmsx-type__label { display: block; font-size: .85rem; font-weight: 600;
color: var(--cs-ink); overflow-wrap: anywhere; }
.csmsx-type__meta { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .2rem; }
.csmsx-tag { font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
border: 1px solid var(--cs-line); background: #fff; color: var(--cs-muted); white-space: nowrap; }
.csmsx-tag.is-auto { border-color: #cfe0ff; background: #f4f8ff; color: #2f5fb3; }
/* کلید فعال/غیرفعال (دکمهٔ submit داخل فرم) */
.csmsx-switch { display: inline-flex; align-items: center; gap: .45rem;
background: none; border: 0; padding: .25rem; cursor: pointer; min-height: 2.4rem; }
.csmsx-switch__track { position: relative; flex: 0 0 auto; width: 44px; height: 25px;
border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.csmsx-switch__track::after { content: ""; position: absolute; top: 3px;
inset-inline-start: 3px; width: 19px; height: 19px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.csmsx-switch.is-on .csmsx-switch__track { background: var(--cs-ok); }
.csmsx-switch.is-on .csmsx-switch__track::after { transform: translateX(-19px); }
.csmsx-switch:focus-visible .csmsx-switch__track { outline: 2px solid var(--cs-brand); outline-offset: 2px; }
.csmsx-switch__text { font-size: .76rem; color: #4a4a4a; white-space: nowrap; min-width: 3.2rem; text-align: start; }
/* شماره‌های اصلی داخل مودال */
.csmsx-phones { display: flex; flex-wrap: wrap; gap: .5rem; }
.csmsx-phone { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .4rem .3rem .65rem;
border: 1px solid var(--cs-line); border-radius: 999px; background: #fff; }
.csmsx-phone__num { direction: ltr; font-size: .82rem; color: var(--cs-ink); }
.csmsx-phone .sms-primary-toggle { border-radius: 999px; font-size: .72rem; height: 1.9rem; padding-inline: .6rem; }
.csmsx-empty { font-size: .8rem; color: var(--cs-muted); margin: 0; }
/* ارسال سفارشی */
.csmsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
.csmsx-chip { font-size: .72rem; padding: .2rem .55rem; border-radius: 999px;
border: 1px solid var(--cs-line); background: #fff; color: #4a4a4a; cursor: pointer;
direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; }
.csmsx-chip:hover { border-color: var(--cs-brand); color: var(--cs-brand); }
.csmsx-counter { display: flex; justify-content: space-between; gap: .5rem;
font-size: .74rem; color: var(--cs-muted); margin-top: .4rem; }
/* ---------- واکنش‌گرا ---------- */
@media (max-width: 768px) {
.csmsx-head { padding: .9rem; }
.csmsx-head__actions { width: 100%; }
.csmsx-head__actions .button { flex: 1 1 auto; }
.csmsx-filters { padding: .75rem; }
.csmsx-filters select { min-width: 0; width: 100%; }
.csmsx-filters .control { flex: 1 1 45%; }
.csmsx-filters__note { margin-inline-start: 0; width: 100%; }
.csmsx-modal__body { max-height: 72vh; }
/* موبایل: جدول به کارت تبدیل می‌شود. اسکرول افقی روی موبایل، متن پیامک را
می‌بُرد و خواندن سوابق را عملاً غیرممکن می‌کرد؛ در این حالت هر پیامک یک
کارت کامل با برچسب هر خانه است و هیچ اسکرول افقی لازم نیست. */
.csmsx-history__scroll { overflow: visible; }
.csmsx-history table, .csmsx-history tbody, .csmsx-history tr,
.csmsx-history td { display: block; width: auto !important; }
.csmsx-history thead { display: none; }
.csmsx-history tbody tr { padding: .7rem .85rem; border-bottom: 1px solid var(--cs-line); }
.csmsx-history tbody tr:last-child { border-bottom: 0; }
.csmsx-history tbody td { display: flex; gap: .5rem; align-items: flex-start;
padding: .18rem 0 !important; border: 0 !important; }
.csmsx-history tbody td::before { content: attr(data-label); flex: 0 0 5.5rem;
font-size: .72rem; color: var(--cs-muted); line-height: 1.9; }
.csmsx-history tbody td[data-label=""]::before { content: none; }
/* ستون متن: برچسب بالای متن (نه کنارش) تا عرض خواندن کم نشود. */
.csmsx-history tbody td[data-label="متن"] { display: block; }
.csmsx-history tbody td[data-label="متن"]::before { display: block;
flex: none; margin-bottom: .15rem; }
.csmsx-history tbody td > * { min-width: 0; }
.csmsx-expand { white-space: nowrap; }
.csmsx-msg { max-width: none; }
.csmsx-col-id, .csmsx-col-sent, .csmsx-col-delivery-check { display: none !important; }
}
{% endverbatim %}
</style>
<div id="customerSmsHistory" class="column is-12-mobile is-12-desktop" dir="rtl">
{# ---------- ۱) نوار بالای صفحه: آمار + دکمه‌های تنظیمات ---------- #}
<div class="csmsx-head">
<div class="csmsx-head__main">
<h1 class="csmsx-title">پیامک‌های {{ customer.name_family }}</h1>
<p class="csmsx-sub">
جمعاً <b>{{ total_count }}</b> پیامک در سابقه ·
<b>{{ sms_enabled_count }}</b> از <b>{{ sms_type_total }}</b> نوع پیامک برای این مشتری فعال است.
</p>
<div class="csmsx-stats">
<span class="csmsx-stat is-sent">✅ ارسال‌شده <b>{{ stat_sent }}</b></span>
<span class="csmsx-stat is-pending">⏳ در صف <b>{{ stat_pending }}</b></span>
<span class="csmsx-stat is-failed">⚠️ ناموفق <b>{{ stat_failed }}</b></span>
</div>
</div>
<div class="csmsx-head__actions">
{% if perms.admin_panel.ManageSMS %}
<button type="button" class="button is-link is-light js-csmsx-open" data-target="smsSettingsModal"
aria-haspopup="dialog">
⚙️ تنظیمات پیامک
</button>
<button type="button" class="button is-info is-light js-csmsx-open" data-target="smsCustomModal"
aria-haspopup="dialog">
✉️ ارسال پیامک
</button>
{% endif %}
{% if perms.admin_panel.ManageSMSSettings %}
<a class="button is-white" href="{% url 'admin_panel:sms_templates' %}"
title="ویرایش متن قالب‌های پیامک">🗂️ متن قالب‌ها</a>
{% endif %}
</div>
</div>
{# ---------- ۲) فیلترها ---------- #}
<form method="get" class="csmsx-filters">
<div class="control">
<label for="csmsx-status">وضعیت ارسال</label>
<div class="select">
<select id="csmsx-status" name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<label for="csmsx-delivery">وضعیت تحویل</label>
<div class="select">
<select id="csmsx-delivery" name="delivery">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-link" type="submit">اعمال فیلتر</button>
</div>
{% if status or delivery %}
<div class="control">
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
</div>
{% endif %}
<span class="csmsx-filters__note">
{% if status or delivery %}
نمایش <b>{{ filtered_count }}</b> از <b>{{ total_count }}</b> پیامک
{% else %}
نمایش <b>{{ total_count }}</b> پیامک، جدیدترین‌ها ابتدا
{% endif %}
</span>
</form>
{# ---------- ۳) سوابق ---------- #}
<div class="csmsx-history">
<div class="csmsx-history__scroll">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th class="csmsx-col-id">شناسه</th>
<th style="min-width: 8.5rem;">نوع پیامک</th>
<th style="min-width: 8rem;">گیرنده</th>
<th style="min-width: 16rem;">متن پیامک</th>
<th>وضعیت ارسال</th>
<th style="min-width: 8rem;">وضعیت تحویل</th>
<th style="min-width: 7rem;">تاریخ ایجاد</th>
<th class="csmsx-col-sent" style="min-width: 7rem;">تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="csmsx-col-id csmsx-meta" data-label="شناسه">{{ item.id }}</td>
<td data-label="نوع">
{% if item.sms_type %}
<span class="csmsx-type-chip">{{ item.get_sms_type_display }}</span>
{% else %}
<span class="csmsx-type-chip">سفارشی</span>
{% endif %}
{% if item.attempts %}
<div class="csmsx-meta">تلاش: {{ item.attempts }}</div>
{% endif %}
</td>
<td class="csmsx-meta" dir="ltr" data-label="گیرنده">{{ item.phone_number }}</td>
<td data-label="متن">
<div class="csmsx-msg {% if item.message|length > 170 %}is-clamped{% endif %}">{{ item.message }}</div>
{% if item.message|length > 170 %}
<button type="button" class="csmsx-expand js-csmsx-expand" aria-expanded="false">نمایش کامل</button>
{% endif %}
</td>
<td data-label="ارسال">
<span class="tag csmsx-badge
{% if item.status == 'sent' %}is-success
{% elif item.status == 'failed' %}is-danger
{% elif item.status == 'pending' %}is-warning
{% elif item.status == 'approved' %}is-info
{% else %}is-light{% endif %}">
{{ item.get_status_display }}
</span>
{% if item.last_error %}
<div class="has-text-danger csmsx-meta" title="{{ item.last_error }}">
خطا: {{ item.last_error|truncatechars:80 }}
</div>
{% endif %}
</td>
<td data-label="تحویل">
<span class="tag csmsx-badge
{% if item.delivery_status == 'delivered' %}is-success
{% elif item.delivery_status == 'not_delivered' %}is-danger
{% elif item.delivery_status == 'expired' %}is-danger
{% elif item.delivery_status == 'pending' %}is-warning
{% else %}is-light{% endif %}">
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
</span>
{% if item.delivery_checked_at %}
<div class="csmsx-meta csmsx-col-delivery-check">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
{% endif %}
</td>
<td class="csmsx-meta" data-label="تاریخ">{{ item.created_at|toJalaliDT }}</td>
<td class="csmsx-meta csmsx-col-sent" data-label="ارسال‌شده">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="has-text-centered py-5" data-label="">پیامکی برای این مشتری ثبت نشده است.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if items.has_other_pages %}
<nav class="pagination is-centered mt-4" role="navigation" aria-label="pagination">
{% if items.has_previous %}
<a class="pagination-previous" href="?page={{ items.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% else %}
<a class="pagination-previous" disabled>قبلی</a>
{% endif %}
{% if items.has_next %}
<a class="pagination-next" href="?page={{ items.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% else %}
<a class="pagination-next" disabled>بعدی</a>
{% endif %}
<ul class="pagination-list">
<li><span class="pagination-link">صفحهٔ {{ items.number }} از {{ items.paginator.num_pages }}</span></li>
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block BodyCustomer2 %}
{% if perms.admin_panel.ManageSMS %}
{# ---------- مودال ۱: تنظیمات پیامک (نوع‌ها + شماره‌های اصلی) ---------- #}
<div class="modal csmsx-modal" id="smsSettingsModal" role="dialog" aria-modal="true"
aria-labelledby="smsSettingsTitle">
<div class="modal-background js-csmsx-close"></div>
<div class="modal-content">
<div class="box" dir="rtl">
<div class="csmsx-modal__head">
<div>
<h2 class="csmsx-modal__title" id="smsSettingsTitle">⚙️ تنظیمات پیامک این مشتری</h2>
<p class="csmsx-modal__sub">
هر تغییر بلافاصله ذخیره می‌شود. خاموش‌کردن یک نوع، فقط برای همین مشتری است؛
متن پیامک‌ها از <b>قالب‌های پیامک</b> می‌آید.
</p>
</div>
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
</div>
<div class="csmsx-modal__body">
{% if notice_text and request.GET.open == 'settings' %}
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
<strong>{{ notice_text }}</strong>
</div>
{% endif %}
{# --- بخش الف: شماره‌های اصلی پیامک --- #}
<section class="csmsx-section">
<h3 class="csmsx-section-title">📱 شماره‌های اصلی پیامک</h3>
{% if phone_entries %}
<div class="csmsx-phones">
{% for entry in phone_entries %}
{% if entry.is_mobile %}
<span class="csmsx-phone">
<span class="csmsx-phone__num">{{ entry.display }}</span>
<button type="button"
class="button sms-primary-toggle {% if entry.is_primary %}is-warning{% else %}is-light{% endif %}"
data-url="{% url 'admin_panel:sms_primary_toggle' customer.id %}"
data-phone="{{ entry.raw }}"
data-primary="{% if entry.is_primary %}true{% else %}false{% endif %}"
title="{% if entry.is_primary %}برداشتن از شماره‌های اصلی پیامک{% else %}تعیین به‌عنوان شمارهٔ اصلی پیامک{% endif %}">
{% if entry.is_primary %}★ اصلی{% else %}☆ اصلی{% endif %}
</button>
</span>
{% endif %}
{% endfor %}
</div>
<p class="csmsx-empty mt-2">
پیامک‌ها به شماره‌های «★ اصلی» می‌روند؛ اگر هیچ شماره‌ای اصلی نباشد، شمارهٔ اول مشتری.
</p>
{% else %}
<p class="csmsx-empty">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endif %}
</section>
{# --- بخش ب: فعال/غیرفعال کردن هر نوع پیامک --- #}
<section class="csmsx-section">
<h3 class="csmsx-section-title">
🔔 انواع پیامک
<span class="csmsx-tag">{{ sms_enabled_count }} از {{ sms_type_total }} فعال</span>
</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}"
class="csmsx-type {% if not item.enabled %}is-off{% endif %}">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<span class="csmsx-type__icon" aria-hidden="true">{{ item.icon }}</span>
<div class="csmsx-type__main">
<span class="csmsx-type__label">{{ item.label }}</span>
<span class="csmsx-type__meta">
{% if item.when %}<span class="csmsx-tag">{{ item.when }}</span>{% endif %}
<span class="csmsx-tag {% if item.auto_send %}is-auto{% endif %}">
{% if item.auto_send %}ارسال خودکار{% else %}نیازمند تأیید مدیر{% endif %}
</span>
</span>
</div>
<button type="submit" class="csmsx-switch {% if item.enabled %}is-on{% endif %}"
role="switch" aria-checked="{% if item.enabled %}true{% else %}false{% endif %}"
title="{% if item.enabled %}خاموش کردن{% else %}روشن کردن{% endif %} این نوع پیامک">
<span class="csmsx-switch__track" aria-hidden="true"></span>
<span class="csmsx-switch__text">{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</button>
</form>
{% endfor %}
</section>
</div>
<div class="csmsx-modal__foot">
<button type="button" class="button is-light js-csmsx-close">بستن</button>
</div>
</div>
</div>
</div>
{# ---------- مودال ۲: ارسال پیامک سفارشی ---------- #}
<div class="modal csmsx-modal" id="smsCustomModal" role="dialog" aria-modal="true"
aria-labelledby="smsCustomTitle">
<div class="modal-background js-csmsx-close"></div>
<div class="modal-content">
<form class="box" dir="rtl" method="post"
action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="csmsx-modal__head">
<div>
<h2 class="csmsx-modal__title" id="smsCustomTitle">✉️ ارسال پیامک سفارشی</h2>
<p class="csmsx-modal__sub">
به شماره‌های «★ اصلی» این مشتری ارسال می‌شود و بی‌درنگ در صف قرار می‌گیرد.
</p>
</div>
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
</div>
<div class="csmsx-modal__body">
{% if notice_text and request.GET.open == 'custom' %}
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
<strong>{{ notice_text }}</strong>
</div>
{% endif %}
<div class="csmsx-chips">
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{name}">{name}</button>
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{balance_sep}">{balance_sep}</button>
</div>
<textarea id="csmsxCustomText" class="textarea" name="text" rows="5"
placeholder="متن پیامک…" required
data-counter="#csmsxCustomCounter"></textarea>
<div class="csmsx-counter">
<span>{name} با نام مشتری، {balance_sep} با مانده حساب جایگزین می‌شود.</span>
<span id="csmsxCustomCounter">۰ نویسه</span>
</div>
</div>
<div class="csmsx-modal__foot">
<button type="button" class="button is-light js-csmsx-close">انصراف</button>
<button type="submit" class="button is-info">ارسال پیامک</button>
</div>
</form>
</div>
</div>
<script>
// باز/بسته کردن مودال‌های این صفحه + درج متغیر در متن + شمارندهٔ نویسه.
// بدون کتابخانهٔ جدید؛ کلید Esc و کلیک روی پس‌زمینه هم مودال را می‌بندد.
(function () {
if (window.__csmsxModalInit) { return; }
window.__csmsxModalInit = true;
function openModal(id) {
var modal = document.getElementById(id);
if (!modal) { return; }
modal.classList.add('is-active');
var field = modal.querySelector('textarea');
if (field) { field.focus(); }
}
function closeModal(modal) {
if (modal) { modal.classList.remove('is-active'); }
}
document.addEventListener('click', function (event) {
if (!event.target.closest) { return; }
var opener = event.target.closest('.js-csmsx-open');
if (opener) {
event.preventDefault();
openModal(opener.getAttribute('data-target'));
return;
}
var closer = event.target.closest('.js-csmsx-close');
if (closer) {
event.preventDefault();
closeModal(closer.closest('.modal'));
return;
}
var expand = event.target.closest('.js-csmsx-expand');
if (expand) {
event.preventDefault();
var box = expand.parentNode.querySelector('.csmsx-msg');
if (box) {
var opened = box.classList.toggle('is-clamped') === false;
expand.setAttribute('aria-expanded', opened ? 'true' : 'false');
expand.textContent = opened ? 'خلاصه' : 'نمایش کامل';
}
return;
}
var chip = event.target.closest('.js-csmsx-token');
if (chip) {
event.preventDefault();
var field = document.getElementById('csmsxCustomText');
if (!field) { return; }
var token = chip.getAttribute('data-token') || '';
var start = field.selectionStart == null ? field.value.length : field.selectionStart;
var end = field.selectionEnd == null ? field.value.length : field.selectionEnd;
field.value = field.value.slice(0, start) + token + field.value.slice(end);
field.selectionStart = field.selectionEnd = start + token.length;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.focus();
}
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape' && event.key !== 'Esc') { return; }
var active = document.querySelector('#smsSettingsModal.is-active, #smsCustomModal.is-active');
closeModal(active);
});
document.addEventListener('input', function (event) {
var field = event.target;
if (!field || field.id !== 'csmsxCustomText') { return; }
var target = document.querySelector(field.getAttribute('data-counter'));
if (!target) { return; }
// ارقام فارسی، هم‌خوان با بقیهٔ متن صفحه.
var count = String(field.value.length)
.replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'.charAt(Number(d)); });
target.textContent = count + ' نویسه';
});
// باز شدن خودکار مودال بر اساس ?open= (بعد از ذخیرهٔ تنظیمات یا خطای ارسال).
var wanted = new URLSearchParams(window.location.search).get('open');
if (wanted === 'settings') { openModal('smsSettingsModal'); }
if (wanted === 'custom') { openModal('smsCustomModal'); }
})();
</script>
{% endif %}
{% endblock %}
+187 -78
View File
@@ -136,63 +136,87 @@
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
<div class="modal addNewUser">
<div class="modal addNewUser" role="dialog" aria-modal="true" aria-labelledby="addUserTitle">
<div class="modal-background closeAddUser"></div>
<div class="modal-content">
<form action="{% url 'admin_panel:customersAdd' %}" class="columns is-mobile is-multiline" method="post">
<div class="modal-content add-modal">
<div class="add-modal-card">
<header class="add-modal-head">
<h2 class="title is-5 mb-0" id="addUserTitle">ایجاد مشتری جدید</h2>
<button type="button" class="delete closeAddUser" aria-label="بستن"></button>
</header>
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
{% csrf_token %}
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="add-modal-body">
<div class="columns is-mobile is-multiline m-0">
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addName">نام و نام خانوادگی <span class="has-text-danger">*</span></label>
<div class="control">
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
<input required name="an_name" id="addName" class="input an_name" type="text"
autocomplete="name" placeholder="نام و نام خانوادگی">
</div>
</div>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="addIdnCode">کدشناسایی</label>
<div class="control">
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
<input name="idnCode" id="addIdnCode" class="input idnCode" type="text"
placeholder="کد شناسایی مشتری">
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="persianDateP">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text"
autocomplete="off" placeholder="مثل: 1400/06/01">
</div>
<p class="help">مبنای محاسبهٔ یادآوری بازدید دوره‌ای.</p>
</div>
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addAddress">آدرس</label>
<div class="control">
<input name="an_address" id="addAddress" class="input an_address" type="text"
autocomplete="street-address" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-12 py-2 px-2">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="field has-addons">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان (مثلاً موبایل)">
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس"
placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-info add-phone-btn">+</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0" checked> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>
</div>
<button type="button" class="button is-info is-light is-small add-phone-btn"
data-target="#dynamic-phones-add">+ افزودن شماره</button>
<p class="help">تیک «اصلی» یعنی گیرندهٔ پیامک‌های این مشتری (می‌تواند چند شماره باشد).</p>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="DcreateCS" value="ایجاد مشتری" type="submit">
</div>
<footer class="add-modal-foot">
<button class="button is-success" id="DcreateCS" type="submit">ایجاد مشتری</button>
<button class="button is-light closeAddUser" type="button">انصراف</button>
</footer>
</form>
</div>
</div>
<button class="modal-close is-large closeAddUser" aria-label="close"></button>
</div>
@@ -252,51 +276,131 @@
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* --- مودال ایجاد مشتری: کارت با سربرگ/پابرگ چسبان و بدنهٔ اسکرول‌شو --- */
/* .add-modal همان .modal-content بولماست؛ سقف ارتفاع و max-width پیش‌فرضش را خنثی
می‌کنیم تا کارت خودش اندازه را تعیین کند (وگرنه پابرگ بریده می‌شود). */
.add-modal {
overflow: visible; max-height: none; max-width: 760px;
width: 100%; margin: 0 auto; padding: 0 .5rem;
}
.add-modal-card {
display: flex; flex-direction: column;
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
width: 100%; max-width: 720px; margin: 0 auto;
max-height: calc(100vh - 120px);
}
/* فرم باید خودش ستون فلکس باشد تا بدنه بتواند اسکرول شود و پابرگ پایین بماند. */
.add-modal-card > form {
display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
}
.add-modal-head {
display: flex; align-items: center; justify-content: space-between;
gap: .5rem; padding: .75rem 1rem;
border-bottom: 1px solid #eee; background: #fff; flex: 0 0 auto;
}
.add-modal-body {
overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: .25rem .5rem; flex: 1 1 auto; min-height: 0;
}
.add-modal-foot {
display: flex; gap: .5rem; padding: .75rem 1rem;
border-top: 1px solid #eee; background: #fafafa; flex: 0 0 auto;
}
.add-modal-foot .button { min-height: 2.6em; }
/* --- ردیف شمارهٔ تماس: دسکتاپ یک‌خطی، موبایل عمودی --- */
.phone-row {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
padding: .5rem; margin-bottom: .5rem;
border: 1px solid #e6e6e6; border-radius: 8px; background: #fafafa;
}
.phone-row-fields { display: flex; flex-wrap: wrap; gap: .5rem; flex: 1 1 320px; min-width: 0; }
.phone-row-fields .phone-title { flex: 0 0 140px; min-width: 0; }
.phone-row-fields .phone-number { flex: 1 1 180px; min-width: 0; }
.phone-row-actions { display: flex; align-items: center; gap: .75rem; white-space: nowrap; }
.phone-primary { display: flex; align-items: center; gap: .35rem; font-size: .9rem; }
@media (max-width: 768px) {
.add-modal-card { max-height: calc(100vh - 32px); border-radius: 8px; }
/* dvh روی مرورگرهای موبایل نوار آدرس را هم حساب می‌کند. */
.add-modal-card { max-height: calc(100dvh - 32px); }
.phone-row-fields { flex: 1 1 100%; }
.phone-row-fields .phone-title,
.phone-row-fields .phone-number { flex: 1 1 100%; }
.phone-row-actions { flex: 1 1 100%; justify-content: space-between; }
.add-modal-foot { flex-direction: column-reverse; }
.add-modal-foot .button { width: 100%; }
}
</style>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
var PHONE_ROW = `
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس" placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0"> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>`;
$(this).closest('div[id^="dynamic-phones"]').append(phoneRow);
function reindexPhones() {
$('#dynamic-phones-add .phone-row').each(function(i) {
$(this).find('input[name="phone_primary"]').val(i);
});
}
// همیشه دست‌کم یک شماره «اصلی» بماند (شمارهٔ اول پیش‌فرضِ گیرندهٔ پیامک است).
function ensurePrimary() {
var $rows = $('#dynamic-phones-add .phone-row');
if ($rows.length && $rows.find('input[name="phone_primary"]:checked').length === 0) {
$rows.first().find('input[name="phone_primary"]').prop('checked', true);
}
}
reindexPhones();
$(document).on('click', '.add-phone-btn', function() {
// data-target صریح (وقتی دکمه بیرونِ ظرف است)؛ وگرنه نزدیک‌ترین ظرف شماره‌ها.
var $target = $(this).data('target')
? $($(this).data('target'))
: $(this).closest('div[id^="dynamic-phones"]');
$target.append(PHONE_ROW);
reindexPhones();
$target.find('.phone-row').last().find('.phone-number').focus();
});
$(document).on('click', '.remove-phone-btn', function() {
$(this).closest('.field.has-addons').remove();
$(this).closest('.phone-row').remove();
reindexPhones();
ensurePrimary();
});
$("#createCS").click(function(){
var u_name = $(".addNewUser").find('.an_name').val();
var u_address = $(".addNewUser").find('.an_address').val();
var u_phone1 = $(".addNewUser").find('#phone-1').val();
var u_phone2 = $(".addNewUser").find('#phone-2').val();
var u_phone3 = $(".addNewUser").find('#phone-3').val();
var u_installDate = $(".addNewUser").find('.installDate').val();
alert("Ok send To server");
function resetAddForm() {
$('#addCustomerForm').find('input.an_name, input.an_address, input.installDate, input.idnCode').val('');
$('#dynamic-phones-add').empty().append(PHONE_ROW);
reindexPhones();
ensurePrimary();
}
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
//Refresh page
resetAddForm();
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
@@ -304,20 +408,25 @@ $(document).ready(function() {
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
});
$("#addCustomerForm").on('submit', function(){
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا داده‌ها را بسته است.
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
});
$("#addNewUser").click(function(){
$(".addNewUser").addClass("is-active")
resetAddForm();
$(".addNewUser").addClass("is-active");
$('#addName').trigger('focus');
});
$(document).on('keydown', function(e){
if (e.key === 'Escape' && $(".addNewUser").hasClass("is-active")) {
$(".addNewUser").removeClass("is-active");
resetAddForm();
}
});
$("#openSearchBox").click(function(){
$(".SearchBox").addClass("is-active")
});
+597 -174
View File
@@ -2,219 +2,642 @@
{% load custom_tags %}
{% block bodyPage %}
<div class="">
<div class="columns m-0">
<style>
/* ===================================================================
صفحهٔ انتظار بازدید — mobile first
مقیاس فاصله: ۰٫۲۵rem (۴px) — همهٔ فاصله‌ها مضرب همین واحد است.
=================================================================== */
.ez-wrap { padding: .5rem; }
@media (min-width: 769px) { .ez-wrap { padding: 1rem 1.25rem; } }
<div class="column is-12">
<div class="buttons">
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded mr-0">جستجو</a>
/* ---------- نوار آمار ---------- */
.ez-stats-head { margin: 0 0 .55rem; }
.ez-stats-title { font-size: .92rem; font-weight: 800; color: #0f172a; margin: 0; }
.ez-stats-hint {
font-size: .72rem; color: #64748b; margin: .2rem 0 0; line-height: 1.7;
}
.ez-stats-hint b { color: #334155; }
.ez-stats {
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(11rem, 1fr);
gap: .5rem; overflow-x: auto; padding-bottom: .35rem;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); overflow: visible; }
}
.ez-stat {
display: flex; flex-direction: column; gap: .18rem;
border: 1px solid #e2e8f0; border-radius: .625rem; background: #fff;
padding: .55rem .65rem .6rem; text-decoration: none; color: #334155;
border-top: 3px solid #cbd5e1;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.ez-stat:hover { border-color: #94a3b8; box-shadow: 0 1px 6px rgba(15, 23, 42, .08); }
/* نوار رنگی بالای هر کارت = همان رنگ برچسب وضعیت در جدول */
.ez-stat.total { border-top-color: #64748b; }
.ez-stat.danger { border-top-color: #dc2626; }
.ez-stat.success { border-top-color: #16a34a; }
.ez-stat.warning { border-top-color: #d97706; }
.ez-stat.link { border-top-color: #0284c7; }
.ez-stat.violet { border-top-color: #7c3aed; }
.ez-stat.followup { border-top-color: #0e7490; }
.ez-stat-top {
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
}
.ez-stat .ez-lbl {
font-size: .74rem; font-weight: 700; color: #334155; line-height: 1.35;
}
.ez-stat .ez-num {
font-size: 1.25rem; font-weight: 800; line-height: 1.1;
font-variant-numeric: tabular-nums; flex: 0 0 auto;
}
.ez-stat .ez-desc {
font-size: .68rem; color: #7c8798; line-height: 1.6;
}
.ez-stat.is-active {
border-color: #33b5d3; background: #f1fbff;
box-shadow: inset 0 -3px 0 #33b5d3, 0 1px 6px rgba(51, 181, 211, .18);
}
.ez-stat.is-active .ez-lbl { color: #0e7490; }
.ez-stat.is-active .ez-desc { color: #0e7490; }
/* ---------- تفکیک تأخیر ---------- */
.ez-legend {
display: flex; flex-wrap: wrap; gap: .25rem .9rem;
margin: .6rem 0 .5rem; font-size: .72rem; color: #64748b;
}
.ez-legend b { font-variant-numeric: tabular-nums; color: #334155; }
/* ---------- نوار تاریخ (نمای «نیازمند پیگیری») ---------- */
.ez-datebar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: .4rem;
padding: .5rem .6rem; margin: .5rem 0 .4rem;
background: #f8fbfd; border: 1px solid #d9e8f0; border-radius: .5rem;
}
.ez-datebar .button { margin: 0 !important; }
.ez-dateform { margin: 0; }
.ez-datefield { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-datefield-row { display: flex; gap: .25rem; align-items: center; }
.ez-datefield-row .input {
height: 2.1rem; font-size: .78rem; width: 8.5rem;
font-variant-numeric: tabular-nums; direction: ltr; text-align: center;
}
.ez-datenote {
flex: 1 1 100%; font-size: .7rem; color: #64748b; line-height: 1.7;
}
.ez-datenote b { color: #334155; }
@media (min-width: 700px) {
.ez-datenote { flex: 1 1 auto; }
}
/* ---------- هشدار مینیمال «پیگیری‌های عقب‌افتاده» ---------- */
.ez-overdue-alert,
.ez-overdue-mode {
flex: 1 1 100%; display: inline-flex; align-items: center; gap: .35rem;
font-size: .72rem; line-height: 1.6; text-decoration: none;
}
.ez-overdue-alert { color: #b91c1c; font-weight: 600; }
.ez-overdue-alert:hover { color: #991b1b; text-decoration: underline; }
.ez-overdue-alert b { font-variant-numeric: tabular-nums; font-size: .8rem; }
.ez-overdue-dot {
width: .42rem; height: .42rem; border-radius: 50%;
background: #dc2626; flex: 0 0 auto;
}
.ez-overdue-mode {
color: #7f1d1d; background: #fef2f2;
border: 1px solid #fecaca; border-radius: .375rem; padding: .2rem .5rem;
}
.ez-overdue-mode b { font-variant-numeric: tabular-nums; }
.ez-overdue-mode a {
color: #b91c1c; font-weight: 700; text-decoration: none;
margin-inline-start: .35rem;
}
.ez-overdue-mode a:hover { text-decoration: underline; }
/* ---------- نوار فیلتر ---------- */
.ez-filters {
display: flex; flex-wrap: wrap; align-items: flex-end;
gap: .5rem; margin: .6rem 0 .4rem;
}
.ez-filter { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-filter-grow { flex: 1 1 18rem; min-width: 14rem; }
.ez-filter-lbl { font-size: .68rem; color: #64748b; font-weight: 700; }
.ez-filters .select select { font-size: .76rem; height: 2.1rem; }
.ez-search { display: flex; gap: .25rem; align-items: center; }
.ez-search .input { height: 2.1rem; font-size: .76rem; }
.ez-search .button { margin: 0 !important; }
.ez-clear { margin: 0 !important; }
/* ---------- سرستون‌های مرتب‌شدنی ---------- */
.ez-th > a {
display: inline-flex; align-items: center; gap: .15rem;
color: #06323d; text-decoration: none; white-space: nowrap;
}
.ez-th > a:hover { text-decoration: underline; }
.ez-th.is-sorted > a { color: #0b4e60; }
.ez-arrow { font-size: .62rem; opacity: .55; }
.ez-th.is-sorted .ez-arrow { opacity: 1; }
/* ---------- تفکیک تأخیر (کلیک‌پذیر) ---------- */
.ez-legend a {
color: #64748b; text-decoration: none; padding: .05rem .35rem;
border-radius: .25rem; white-space: nowrap;
}
.ez-legend a:hover { background: #f1f5f9; }
.ez-legend a.is-active { background: #33b5d3; color: #fff; }
.ez-legend a.is-active b { color: #fff; }
/* ---------- جدول ---------- */
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
@media (min-width: 900px) { .ez-table { table-layout: fixed; } }
.ez-table thead th {
position: sticky; top: 0; z-index: 2; background: #33b5d3; color: #06323d;
font-size: .72rem; font-weight: 700; padding: .5rem .35rem; white-space: nowrap;
border: 0; border-bottom: 1px solid #22a5c1;
}
.ez-table tbody td {
padding: .45rem .3rem; vertical-align: middle; text-align: center;
line-height: 1.4; border: 0; border-bottom: 1px solid #eef2f6;
overflow-wrap: anywhere;
}
/* تاریخ‌ها و شماره‌ها هرگز به دو خط نشکنند */
.ez-table tbody td.ez-nowrap { white-space: nowrap; }
.ez-table tbody tr:hover td { background: #f8fbfd; }
/* شمارندهٔ ردیف: به‌جای یک ستون کامل، چیپ کنار نام */
.ez-idx {
display: inline-block; min-width: 1.55rem; margin-inline-end: .3rem;
padding: .05rem .25rem; border-radius: .3rem; background: #f1f5f9;
color: #64748b; font-size: .68rem; font-weight: 700;
font-variant-numeric: tabular-nums; text-align: center;
}
.ez-name { font-weight: 700; font-size: .84rem; color: #0f172a; }
.ez-name:hover { color: #0e7490; }
.ez-sub { font-size: .68rem; color: #94a3b8; margin-top: .1rem; }
/* اعداد و شماره‌ها */
.ez-num { font-variant-numeric: tabular-nums; }
.ez-phone {
direction: ltr; unicode-bidi: isolate;
font-variant-numeric: tabular-nums; letter-spacing: .01em;
}
.ez-muted { color: #cbd5e1; }
.ez-badge {
display: inline-block; padding: .1rem .45rem; border-radius: 999px;
font-size: .68rem; font-weight: 700; white-space: nowrap; line-height: 1.6;
}
.ez-badge.danger { background: #fee2e2; color: #b91c1c; }
.ez-badge.success { background: #dcfce7; color: #15803d; }
.ez-badge.warning { background: #fef3c7; color: #b45309; }
.ez-badge.mixed { background: #e0f2fe; color: #0369a1; }
.ez-badge.coordination { background: #ede9fe; color: #6d28d9; }
/* تأخیر به‌صورت چیپ (خوانا در یک نگاه) */
.ez-late {
display: inline-block; min-width: 4.4rem; padding: .1rem .4rem;
border-radius: .3rem; font-size: .72rem; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ez-late.lv0 { background: #f8fafc; color: #94a3b8; }
.ez-late.lv1 { background: #fffbeb; color: #b45309; }
.ez-late.lv2 { background: #fef2f2; color: #dc2626; }
.ez-late.lv3 { background: #fde8e8; color: #991b1b; }
/* آدرس: حداکثر دو خط (بدون شکستن چیدمان) */
.ez-addr {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; text-align: right; font-size: .78rem; color: #475569;
}
/* ---------- حالت کارت (موبایل) ---------- */
.ez-only-desktop { display: none !important; }
@media (max-width: 899px) {
.ez-table thead { display: none; }
.ez-table, .ez-table tbody, .ez-table tr, .ez-table td { display: block; width: 100%; }
.ez-table tbody tr {
display: flex; flex-wrap: wrap; align-items: baseline;
background: #fff; border: 1px solid #e5e7eb; border-radius: .625rem;
margin-bottom: .5rem; padding: .55rem .7rem;
}
.ez-table tbody tr:hover td { background: transparent; }
.ez-table tbody td {
flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: baseline;
gap: .3rem; padding: .12rem 0; border: 0; text-align: right;
}
.ez-table tbody td::before {
content: attr(data-label); flex: 0 0 6.5rem;
font-size: .7rem; color: #94a3b8;
}
/* سرِ کارت: نام مشتری، بدون برچسب */
.ez-table tbody td.ez-card-head {
padding-bottom: .35rem; margin-bottom: .25rem;
border-bottom: 1px dashed #eef2f6;
}
.ez-table tbody td.ez-card-head::before,
.ez-table tbody td.ez-nolabel::before { content: none; }
.ez-actions { flex: 1 0 100%; display: flex; gap: .35rem; margin-top: .4rem; }
.ez-actions .button { margin: 0 !important; flex: 1 1 0; }
.ez-addr { -webkit-line-clamp: 3; }
.ez-legend { font-size: .7rem; }
}
@media (min-width: 900px) {
.ez-table thead { display: table-header-group; }
.ez-table tbody { display: table-row-group; }
.ez-table tr { display: table-row; }
.ez-table td { display: table-cell; }
.ez-table td::before { content: none; }
.ez-only-desktop { display: table-cell !important; }
.ez-only-mobile { display: none !important; }
.ez-actions { display: none; }
}
</style>
<div class="ez-wrap">
<div class="columns m-0">
<div class="column is-12 p-0">
{# ===== نوار آمار: چهار کارت، هر عدد لینک فیلتر ===== #}
<div class="ez-stats-head">
<p class="ez-stats-title">وضعیت پیگیری مشتریان</p>
<p class="ez-stats-hint">
«عقب‌افتاده» یعنی از آخرین بازدید
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = تعیین‌تکلیف‌نشده + تعیین‌تکلیف‌شده</b>.
</p>
</div>
<div class="ez-stats">
{# کارت «نیازمند پیگیری امروز» — مستقل از «عقب‌افتاده»؛ بازدیدها حذف می‌شوند. #}
<a href="{{ view_links.today }}"
class="ez-stat followup {% if view == followup_view %}is-active{% endif %}"
title="مشتریانی که موعد پیگیری/تماسشان امروز است و برایشان بازدید برنامه‌ریزی نشده">
<span class="ez-stat-top">
<span class="ez-lbl">نیازمند پیگیری امروز</span>
<span class="ez-num has-text-info">{{ counts.followup_today }}</span>
</span>
<span class="ez-desc">موعد تماسشان امروز است — بازدید ندارند</span>
</a>
<a href="{{ view_links.all }}"
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
title="همهٔ مشتریانی که بازدیدشان عقب افتاده، با هر وضعیت پیگیری">
<span class="ez-stat-top">
<span class="ez-lbl">همهٔ مشتریان عقب‌افتاده</span>
<span class="ez-num">{{ counts.total }}</span>
</span>
<span class="ez-desc">با هر وضعیت پیگیری</span>
</a>
<a href="{{ view_links.uncoordinated }}"
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
title="مشتریانی که هنوز هیچ وقت بازدید/پیگیری برایشان تعیین نشده است">
<span class="ez-stat-top">
<span class="ez-lbl">تعیین تکلیف نشده</span>
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
</span>
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
</a>
<a href="{{ view_links.coordinated }}"
class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
title="مشتریانی که وقت بازدید یا پیگیریِ بعدی برایشان تعیین شده است">
<span class="ez-stat-top">
<span class="ez-lbl">تعیین تکلیف شده</span>
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
</span>
<span class="ez-desc">وقت بعدی‌شان مشخص است</span>
</a>
</div>
{# ===== نوار تاریخ در نمای «نیازمند پیگیری» ===== #}
{% if is_followup_view %}
<div class="ez-datebar">
<a href="{{ followup_nav.prev }}" class="button is-small is-light"
title="روز قبل">‹ روز قبل</a>
<form method="get" class="ez-dateform">
<input type="hidden" name="view" value="{{ followup_view }}">
{% if searchVal %}<input type="hidden" name="searchVal" value="{{ searchVal }}">{% endif %}
{% if searchVal %}<input type="hidden" name="searchOn" value="{{ searchOn }}">{% endif %}
<label class="ez-datefield">
<span class="ez-filter-lbl">تاریخ پیگیری (شمسی)</span>
<span class="ez-datefield-row">
<input type="text" name="fdate" id="followupDatePicker"
value="{{ followup_date_jalali }}" class="input is-small ss02"
placeholder="۱۴۰۵/۰۸/۲۹" autocomplete="off" inputmode="numeric">
<button type="submit" class="button is-info is-small">نمایش</button>
</span>
</label>
</form>
<a href="{{ followup_nav.next }}" class="button is-small is-light"
title="روز بعد">روز بعد ›</a>
{% if not followup_nav.is_today %}
<a href="{{ followup_nav.today }}" class="button is-success is-light is-small">
بازگشت به امروز
</a>
{% endif %}
<span class="ez-datenote">
{% if followup_nav.is_today %}
فقط موعدهای <b>امروز</b> نمایش داده می‌شوند.
{% else %}
نمایش موعدهای <b>{{ followup_date_jalali }}</b> (نه امروز).
{% endif %}
مشتریانی که برایشان <b>بازدید</b> گذاشته شده در این فهرست نمی‌آیند.
</span>
{# هشدار مینیمال: موعدهایی که گذشته‌اند و هنوز تعیین تکلیف نشده‌اند. #}
{% if counts.followup_overdue and not show_missed %}
<a class="ez-overdue-alert"
href="{{ followup_overdue_link }}"
title="موعد پیگیری‌شان گذشته و هنوز تعیین تکلیف نشده‌اند — برای دیدن فهرستشان کلیک کنید">
<span class="ez-overdue-dot" aria-hidden="true"></span>
<b>{{ counts.followup_overdue }}</b> مورد نیازمند پیگیری از روزهای قبل باقی مانده
</a>
{% endif %}
{% if show_missed %}
<span class="ez-overdue-mode">
نمایش <b>{{ counts.followup_overdue }}</b> پیگیریِ عقب‌افتاده
<a href="{{ followup_ontime_link }}" title="بازگشت به فهرست امروز">✕ بازگشت به امروز</a>
</span>
{% endif %}
</div>
{% endif %}
{# ===== نوار فیلتر و جست‌وجو: یک ردیف، همهٔ کنترل‌ها ===== #}
<form class="ez-filters" method="get" action="">
{% if is_followup_view %}
<input type="hidden" name="fdate" value="{{ followup_date_jalali }}">
{% endif %}
{# حالت «فقط عقب‌افتاده‌ها» هنگام جست‌وجو/فیلتر حفظ شود. #}
{% if show_missed %}
<input type="hidden" name="missed" value="1">
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">نوع قرار</span>
<span class="select is-small">
<select name="view" class="ez-auto-submit">
<option value="today" {% if is_followup_view %}selected{% endif %}>نیازمند پیگیری (تاریخ انتخابی)</option>
<option value="all" {% if view == 'all' %}selected{% endif %}>همه (با و بدون قرار)</option>
<option value="uncoordinated" {% if view == 'uncoordinated' %}selected{% endif %}>تعیین تکلیف نشده</option>
<option value="coordinated" {% if view == 'coordinated' %}selected{% endif %}>تعیین تکلیف شده (همه)</option>
<option value="manual" {% if view == 'manual' %}selected{% endif %}>تعویق دستی</option>
<option value="scheduled" {% if view == 'scheduled' %}selected{% endif %}>بازدید برنامه‌ریزی‌شده</option>
<option value="coordination" {% if view == 'coordination' %}selected{% endif %}>پیگیری تلفنی</option>
</select>
</span>
</label>
{% if not is_followup_view %}
<label class="ez-filter">
<span class="ez-filter-lbl">میزان تأخیر</span>
<span class="select is-small">
<select name="delay" class="ez-auto-submit">
<option value="" {% if not delay %}selected{% endif %}>همه</option>
<option value="lt30" {% if delay == 'lt30' %}selected{% endif %}>تا ۳۰ روز ({{ counts.buckets.lt30 }})</option>
<option value="30to90" {% if delay == '30to90' %}selected{% endif %}>۳۰ تا ۹۰ روز ({{ counts.buckets.30to90 }})</option>
<option value="90to180" {% if delay == '90to180' %}selected{% endif %}>۹۰ تا ۱۸۰ روز ({{ counts.buckets.90to180 }})</option>
<option value="gt180" {% if delay == 'gt180' %}selected{% endif %}>بیش از ۶ ماه ({{ counts.buckets.gt180 }})</option>
</select>
</span>
</label>
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">مرتب‌سازی</span>
<span class="select is-small">
<select name="sort" class="ez-auto-submit">
<option value="last_visit" {% if sort == 'last_visit' and sort_dir == 'asc' %}selected{% endif %}>قدیمی‌ترین بازدید</option>
<option value="last_visit-desc" {% if sort == 'last_visit' and sort_dir == 'desc' %}selected{% endif %}>جدیدترین بازدید</option>
<option value="overdue" {% if sort == 'overdue' %}selected{% endif %}>بیشترین تأخیر</option>
<option value="name" {% if sort == 'name' %}selected{% endif %}>نام (الفبا)</option>
<option value="status" {% if sort == 'status' %}selected{% endif %}>وضعیت قرار</option>
<option value="coordination" {% if sort == 'coordination' %}selected{% endif %}>تاریخ پیگیری</option>
</select>
</span>
</label>
<label class="ez-filter ez-filter-grow">
<span class="ez-filter-lbl">جست‌وجو</span>
<span class="ez-search">
<input type="text" name="searchVal" value="{{ searchVal }}"
class="input is-small" placeholder="نام، تلفن، کد یا آدرس">
<span class="select is-small">
<select name="searchOn">
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام</option>
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد</option>
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
</select>
</span>
<button type="submit" class="button is-info is-small">جست‌وجو</button>
</span>
</label>
{% if has_filters %}
<a href="{{ clear_link }}" class="button is-light is-small ez-clear">✕ حذف فیلترها</a>
{% endif %}
</form>
{# تفکیک مدت تأخیر در نمای جاری (کلیک‌پذیر) #}
{% if not is_followup_view %}
<div class="ez-legend">
<span>میزان تأخیر در همین نما:</span>
<a href="{{ delay_links.ez_all }}" class="{% if not delay %}is-active{% endif %}">
<b>{{ counts.view_total }}</b> همه
</a>
<a href="{{ delay_links.lt30 }}" class="{% if delay == 'lt30' %}is-active{% endif %}">
<b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز
</a>
<a href="{{ delay_links.30to90 }}" class="{% if delay == '30to90' %}is-active{% endif %}">
<b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز
</a>
<a href="{{ delay_links.90to180 }}" class="{% if delay == '90to180' %}is-active{% endif %}">
<b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز
</a>
<a href="{{ delay_links.gt180 }}" class="{% if delay == 'gt180' %}is-active{% endif %}">
<b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه
</a>
</div>
{% endif %}
<div class="table-container">
<table class="table has-text-centered is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<!-- Your table content -->
<table class="table ez-table">
<colgroup>
{% for col in table_columns %}
<col style="width: {{ col.width }}%">
{% endfor %}
</colgroup>
<thead>
<tr style="
background: #33b5d3;
">
<th >ردیف</th>
<th style="min-width: 200px;">نام و نام خانودگی</th>
<th >آخرین بازدید</th>
<th >تلفن</th>
<th style="min-width: 400px;">آدرس</th>
<tr>
{% for col in sort_header %}
<th class="ez-th {% if col.active %}is-sorted{% endif %}">
<a href="{{ col.link }}" title="مرتب‌سازی بر اساس {{ col.label }}">
{{ col.label }}<span class="ez-arrow">{% if col.active %}{% if col.dir == 'asc' %}▲{% else %}▼{% endif %}{% else %}⇅{% endif %}</span>
</a>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr>
<td>{{ forloop.counter }}</td>
<td>
{# سرِ ردیف: چیپ شماره + نام (شمارهٔ ردیف دیگر ستون جداگانه ندارد) #}
<td data-label="نام" class="ez-card-head" style="text-align: right;">
<input class="is-hidden fid fidv-20" value="20">
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name">
<span class="ez-idx">{{ forloop.counter0|add:customers.start_index }}</span>
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name ez-name">
{% if customer.name_family|length >= 2 %}{{ customer.name_family }}{% else %}نامعلوم{% endif %}
</a>
{% if customer.idCode %}<div class="ez-sub">کد: {{ customer.idCode }}</div>{% endif %}
</td>
<td>
<div class="tdvl lastView">
{{ customer.last_bazdid_dt | toJalali }}
</div>
{% with appt=appointment_rows|row_for:customer.id %}
<td data-label="وضعیت" class="ez-nolabel">
{% if appt.source_count > 1 %}
<span class="ez-badge mixed">{{ appt.source_count }} قرار</span>
{% elif appt.manual %}
<span class="ez-badge warning">قرار دستی</span>
{% elif appt.scheduled %}
<span class="ez-badge success">بازدید برنامه</span>
{% elif appt.coordination %}
<span class="ez-badge coordination">هماهنگی</span>
{% else %}
<span class="ez-badge danger">بدون قرار</span>
{% endif %}
</td>
<td data-label="قرار دستی" class="ez-num ez-nowrap">
{% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="بازدید برنامه" class="ez-num ez-nowrap">
{% if appt.scheduled %}{{ appt.scheduled|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="هماهنگی" class="ez-num ez-nowrap">
{% if appt.coordination %}{{ appt.coordination|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
</td>
{% if 'overdue' not in hidden_columns %}
<td data-label="تأخیر" class="ez-nowrap">
{% if appt.overdue_days >= 180 %}
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
{% elif appt.overdue_days >= 90 %}
<span class="ez-late lv2">{{ appt.overdue_days }} روز</span>
{% elif appt.overdue_days >= 30 %}
<span class="ez-late lv1">{{ appt.overdue_days }} روز</span>
{% else %}
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
{% endif %}
</td>
{% endif %}
{% endwith %}
<td>
<div class="tdvl mobile_1">
{{ customer.phone1 }}
</div>
<div class="tdvl is-hidden mobile_2">
09384999655
</div>
<div class="tdvl is-hidden mobile_3">
{{ customer.phone2 }}
<td data-label="تلفن" class="ez-nowrap">
<span class="tdvl mobile_1 ez-phone">
{% if customer.phone1 %}{{ customer.phone1 }}{% else %}<span class="ez-muted">—</span>{% endif %}
</span>
<span class="tdvl is-hidden mobile_2">{{ customer.phone2 }}</span>
<span class="tdvl is-hidden mobile_3">{{ customer.phone3 }}</span>
{# دکمه‌های اقدام سریع — فقط موبایل #}
<div class="ez-actions">
{% if customer.phone1 %}
<a href="tel:{{ customer.phone1 }}" class="button is-success is-small">تماس</a>
{% endif %}
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="button is-info is-small">ثبت اقدام</a>
</div>
</td>
<td>
<div class="tdvl address">
{{ customer.address }}
</div>
<td data-label="آدرس">
<div class="tdvl address ez-addr" title="{{ customer.address }}">{{ customer.address }}</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="{{ column_count }}" class="has-text-centered py-5">
<span class="has-text-grey">
{% if is_followup_view %}
برای تاریخ {{ followup_date_jalali }} موردی برای پیگیری نیست.
{% else %}
موردی در این نما پیدا نشد.
{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="">
{# ===== صفحه‌بندی ===== #}
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
{% if customers.has_previous %}
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
{% endif %}
<a class="button is-danger is-small" href="{{ page_prev }}">صفحه قبل</a>
{% else %}<span></span>{% endif %}
<span class="is-size-7 has-text-grey">
صفحهٔ {{ customers.number }} از {{ customers.paginator.num_pages }} ·
{{ customers.paginator.count }} مورد
</span>
{% if customers.has_next %}
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
{% endif %}
<a class="button is-danger is-small" href="{{ page_next }}">صفحه بعد</a>
{% else %}<span></span>{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="modal SearchBox">
<div class="modal-background closeSearch"></div>
<div class="modal-content">
<!-- -->
<form method="get" class="columns is-mobile is-multiline">
<div class="field column is-7-desktop is-12-mobile m-0">
<label class="label">جستجو کنید</label>
<div class="control">
<input name="searchVal" class="input search" type="text" placeholder="">
</div>
</div>
<div class="field column is-5-desktop is-12-mobile m-0">
<label class="label">فیلد</label>
<div class="select">
<select name="searchOn">
<!--
<option value="name">نام و نام خانوادگی</option>
<option value="code">کدمشتری</option>
<option value="phone">تلفن</option> !-->
<option value="address">آدرس</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="searchbtn" value="جستجو مشتری" type="submit">
</div>
<!-- -->
</form>
</div>
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
<div class="modal BazdidF">
<div class="modal-background closeBazdidF"></div>
<div class="modal-content">
<div class="columns is-mobile is-multiline">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input class="input rf_name" id="bz_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تلفن همراه</label>
<div class="control">
<input class="input rf_mobile" id="phone-1" type="text" placeholder="">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تاریخ</label>
<div class="control">
<input name="bazdidDateTime" id="persianDateP" class="input bz_DateTime" type="text" placeholder="">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">توسط</label>
<div class="select">
<select>
<option>کدام خدمات؟</option>
<option>خدمات 1</option>
<option>خدمات 2</option>
<option>خدمات 3</option>
</select>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">زمان مراجعه</label>
<div class="select">
<select>
<option>زمان را انتخاب کنید</option>
<option>صبح</option>
<option>ظهر</option>
<option>بعد از ظهر</option>
<option>غروب</option>
<option>شب</option>
</select>
</div>
</div>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea class="textarea" placeholder="توضیحات"></textarea> </div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="editCS" value="ایجاد بازدید" type="submit">
</div>
</div>
</div>
<button class="modal-close is-large closeBazdidF" aria-label="close"></button>
</div>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
$(".closeBazdidF").click(function(){
$(".BazdidF").removeClass("is-active")
// تغییر هر دراپ‌داون ⇒ ارسال خودکار فرم فیلترها.
$(".ez-auto-submit").on('change', function () {
$(this).closest('form').trigger('submit');
});
$("#openSearchBox").click(function(){
$(".SearchBox").addClass("is-active")
// مقدار «مرتب‌سازی» با علامتِ جهت ترکیب می‌شود (مثلاً last_visit-desc)
// تا یک دراپ‌داون بتواند هر دو جهت را پوشش دهد.
$(".ez-filters select[name=sort]").on('change', function () {
var value = $(this).val() || '';
var form = $(this).closest('form');
var dash = value.lastIndexOf('-');
if (dash > -1) {
form.find("input[name=sort]").val(value.slice(0, dash));
form.find("input[name=dir]").val(value.slice(dash + 1));
} else {
form.find("input[name=sort]").val(value);
form.find("input[name=dir]").val('');
}
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
$(".openRow").click(function(){
var id = $(this).parent().parent().find('.fid').val().trim();
var name = $(this).parent().parent().find('.name').text().trim();
var mobile_1 = $(this).parent().parent().find('.mobile_1').text().trim();
var address = $(this).parent().parent().find('.address').text().trim();
//alert(id)
$(".BazdidF").find('#bz_name').val(name);
$(".BazdidF").find('#phone-1').val(mobile_1);
//$("#bedehiStatus").val('وضعیت')
$(".BazdidF").addClass("is-active")
// تقویم شمسی برای ناوبری روزهای نمای «نیازمند پیگیری».
// همان کامپوننت pDatepicker که در بقیهٔ صفحه‌ها استفاده شده است.
{% if is_followup_view %}
var $followupDate = $("#followupDatePicker");
if ($followupDate.length && typeof $followupDate.pDatepicker === 'function') {
$followupDate.pDatepicker({
initialValue: false,
initialValueType: 'persian',
format: 'YYYY/MM/DD',
autoClose: true,
calendar: {
persian: { locale: 'fa', leapYearMode: 'astronomical' }
},
onSelect: function () {
// انتخاب تاریخ ⇒ فوراً همان روز نمایش داده شود.
$followupDate.closest('form').trigger('submit');
}
});
}
{% endif %}
});
</script>
{% endblock %}
@@ -0,0 +1,166 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">صف پیامک‌ها</h1>
<div class="notification is-warning is-light">
پیامک‌ها تا زمانی که مدیر آن‌ها را تأیید نکند ارسال نمی‌شوند. صف را بازبینی
کنید، موارد نامناسب را حذف کنید و سپس تأیید بزنید.
<br>
حذف یک مورد، فقط همان نوبت را کنسل می‌کند. یادآوری‌های دوره‌ای (بازدید و ثبت
اطلاعات چک) در <strong>دورهٔ بعدی</strong> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
<br>
پیامک‌های <strong>ارسال‌شده</strong> از این صف بیرون می‌روند؛ فهرست و سابقهٔ
کامل‌شان در <a href="{% url 'admin_panel:sms_report' %}">گزارش ارسال</a> است.
</div>
<div class="columns is-multiline mb-2" dir="rtl">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">کل صف</p>
<p class="title is-5 mb-0">{{ total_count }}</p>
</div>
</div>
<div class="column is-narrow">
<a href="{% url 'admin_panel:sms_report' %}" class="box py-3 px-4 has-text-centered"
title="مشاهدهٔ سوابق ارسال‌شده‌ها در گزارش ارسال">
<p class="heading mb-0">ارسال‌شده (در گزارش)</p>
<p class="title is-5 mb-0 has-text-success">{{ sent_count }}</p>
</a>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{% if status or delivery %}
<p class="mb-3">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد — <a href="{% url 'admin_panel:sms_queue' %}">حذف فیلتر</a></p>
{% endif %}
<form method="get" class="field has-addons mb-3">
<div class="control">
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="delivery">
<option value="">همهٔ دلیوری‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button" type="submit">فیلتر</button></div>
</form>
<form method="post" action="{% url 'admin_panel:sms_queue_bulk' %}" id="bulk-form" class="mb-3">
{% csrf_token %}
<button class="button is-success" name="action" value="approve_all"
onclick="return confirm('همهٔ پیامک‌های «در انتظار تأیید» تأیید و ارسال شوند؟');">
تأیید همهٔ موارد در انتظار ({{ pending_count }})
</button>
<button class="button is-info" name="action" value="approve_selected">تأیید انتخاب‌شده‌ها</button>
<button class="button is-danger" name="action" value="delete_selected"
onclick="return confirm('موارد انتخاب‌شده از صف حذف شوند؟');">حذف انتخاب‌شده‌ها</button>
</form>
<div class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between mb-3">
<span>خالی کردن صف: همهٔ موارد باقی‌مانده (در انتظار تأیید، تأییدشده، در حال پردازش و ناموفق)
حذف می‌شوند و دیگر بازنمی‌گردند. پیامک‌های ارسال‌شده دست‌نخورده می‌مانند (در گزارش ارسال).</span>
<form method="post" action="{% url 'admin_panel:sms_queue_empty' %}"
onsubmit="return confirm('صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
{% csrf_token %}
<button class="button is-danger" type="submit">خالی کردن صف</button>
</form>
</div>
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th><input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(c => c.checked = this.checked)"></th>
<th>شناسه</th><th>مشتری</th><th>نوع</th><th>شماره</th>
<th>وضعیت</th><th>دلیوری</th><th>تلاش</th><th>ایجاد</th><th>ارسال</th><th>خطا</th><th></th>
</tr>
</thead>
<tbody>
{% for item in queue %}
<tr>
<td><input class="row-check" type="checkbox" form="bulk-form" name="ids" value="{{ item.id }}"></td>
<td>{{ item.id }}</td>
<td>
{% if item.customer_id %}
<a href="{% url 'admin_panel:customer' item.customer_id %}">{{ item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>{{ item.get_sms_type_display }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td>{{ item.get_status_display }}</td>
<td>{{ item.get_delivery_status_display|default:'—' }}</td>
<td>{{ item.attempts }}</td>
<td>{{ item.created_at|toJalaliDT }}</td>
<td>{{ item.sent_at|toJalaliDT|default:'—' }}</td>
<td class="is-size-7">{{ item.last_error|truncatechars:60 }}</td>
<td>
<div class="buttons are-small">
{% if item.status == 'pending' %}
<form method="post" action="{% url 'admin_panel:sms_queue_approve' item.id %}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">تأیید</button>
</form>
{% elif item.status == 'failed' %}
<form method="post" action="{% url 'admin_panel:sms_queue_retry' item.id %}">
{% csrf_token %}
<button class="button is-small is-warning" type="submit">ارسال مجدد</button>
</form>
{% endif %}
{% if item.status != 'sent' %}
<form method="post" action="{% url 'admin_panel:sms_queue_delete' item.id %}"
onclick="return confirm('این پیامک از صف حذف شود؟');">
{% csrf_token %}
<button class="button is-small is-danger" type="submit">حذف</button>
</form>
{% else %}
<a class="button is-small is-light"
href="{% url 'admin_panel:sms_report' %}?q={{ item.phone_number }}"
title="این پیامک ارسال شده است؛ سابقهٔ آن در گزارش ارسال است">در گزارش</a>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="12" class="has-text-centered">موردی یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if queue.has_other_pages %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if queue.has_previous %}
<a class="pagination-previous" href="?page={{ queue.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% endif %}
{% if queue.has_next %}
<a class="pagination-next" href="?page={{ queue.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% endif %}
<ul class="pagination-list"><li><span class="pagination-link">صفحه {{ queue.number }} از {{ queue.paginator.num_pages }}</span></li></ul>
</nav>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,189 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">گزارش ارسال پیامک</h1>
<p class="has-text-grey mb-3">
پیامک‌های <strong>تأییدشده و ارسال‌شده</strong> (موارد «در انتظار تأیید» و «لغو‌شده»
در این گزارش نمی‌آیند) به‌همراه «تعداد پیامکی که اپراتور محاسبه کرده»
(بر اساس کدگذاری GSM-7 برای انگلیسی/عدد و UCS-2 برای فارسی/یونیکد) — برای تحلیل هزینه.
</p>
{# --- کارت‌های خلاصه (روی کل نتیجهٔ فیلترشده) --- #}
<div class="columns is-multiline mb-2">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">تعداد پیامک (رکورد)</p>
<p class="title is-5 mb-0">{{ total_records }}</p>
</div>
</div>
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">مجموع پیامک محاسبه‌شده</p>
<p class="title is-5 mb-0 has-text-danger">{{ total_parts }}</p>
</div>
</div>
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">GSM-7 / UCS-2</p>
<p class="title is-6 mb-0">{{ gsm7_parts }} / {{ ucs2_parts }}</p>
</div>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{# --- فرم فیلتر --- #}
<form method="get" class="box">
<div class="columns is-multiline is-variable is-2">
<div class="column is-2">
<label class="label is-small">از تاریخ (شمسی)</label>
<input class="input" type="text" id="dateFromP" name="date_from"
value="{{ date_from }}" placeholder="1404/01/01" autocomplete="off">
</div>
<div class="column is-2">
<label class="label is-small">تا تاریخ (شمسی)</label>
<input class="input" type="text" id="dateToP" name="date_to"
value="{{ date_to }}" placeholder="1404/12/29" autocomplete="off">
</div>
<div class="column is-2">
<label class="label is-small">وضعیت ارسال</label>
<div class="select is-fullwidth">
<select name="status">
<option value="">همه</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">وضعیت تحویل</label>
<div class="select is-fullwidth">
<select name="delivery">
<option value="">همه</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">نوع پیامک</label>
<div class="select is-fullwidth">
<select name="sms_type">
<option value="">همه</option>
{% for value, label in type_choices %}
<option value="{{ value }}" {% if sms_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">جستجو (متن/شماره/نام مشتری)</label>
<input class="input" type="text" name="q" value="{{ query }}" placeholder="جستجو…">
</div>
</div>
<div class="buttons">
<button class="button is-link" type="submit">اعمال فیلتر</button>
{% if has_filter %}
<a class="button is-light" href="{% url 'admin_panel:sms_report' %}">حذف فیلترها</a>
{% endif %}
</div>
</form>
{# --- جدول --- #}
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
<thead>
<tr>
<th>شناسه</th>
<th style="min-width: 16rem;">متن دقیق پیامک</th>
<th style="min-width: 8rem;">شماره گیرنده</th>
<th>نوع پیامک</th>
<th style="min-width: 8rem;">مشتری</th>
<th>سوابق</th>
<th>تعداد پیامک</th>
<th>وضعیت ارسال</th>
<th>تحویل</th>
<th style="min-width: 8rem;">تاریخ ایجاد</th>
<th style="min-width: 8rem;">تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.item.id }}</td>
<td class="smsrep-msg">{{ row.item.message }}</td>
<td dir="ltr">{{ row.item.phone_number }}</td>
<td>{{ row.item.get_sms_type_display|default:'دستی/سفارشی' }}</td>
<td>
{% if row.item.customer_id %}
<a href="{% url 'admin_panel:customer' row.item.customer_id %}">{{ row.item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>
{% if row.item.customer_id %}
<a class="button is-small is-light"
href="{% url 'admin_panel:customerSmsHistory' row.item.customer_id %}"
title="مشاهدهٔ سوابق پیامک این مشتری">سوابق پیامک</a>
{% else %}—{% endif %}
</td>
<td class="has-text-centered">
<span class="tag {% if row.parts > 1 %}is-danger{% else %}is-success{% endif %} is-light"
title="{{ row.encoding }} · {{ row.length }} واحد">{{ row.parts }}</span>
</td>
<td>{{ row.item.get_status_display }}</td>
<td>{{ row.item.get_delivery_status_display|default:'—' }}</td>
<td>{{ row.item.created_at|toJalaliDT }}</td>
<td>{{ row.item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr><td colspan="11" class="has-text-centered">پیامکی با این شرایط یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page.has_other_pages %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page.has_previous %}
<a class="pagination-previous" href="?{{ base_query }}&page={{ page.previous_page_number }}">قبلی</a>
{% else %}<a class="pagination-previous" disabled>قبلی</a>{% endif %}
{% if page.has_next %}
<a class="pagination-next" href="?{{ base_query }}&page={{ page.next_page_number }}">بعدی</a>
{% else %}<a class="pagination-next" disabled>بعدی</a>{% endif %}
<ul class="pagination-list">
<li><span class="pagination-link">صفحهٔ {{ page.number }} از {{ page.paginator.num_pages }}</span></li>
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* متن پیامک با حفظ شکست خطوط و شکستن متن‌های طولانی/بدون‌فاصله */
.smsrep-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 26rem; line-height: 1.9; }
</style>
{% endblock %}
{% block jsEnd %}
<script>
(function () {
if (typeof kamaDatepicker !== 'function') { return; }
var opts = { buttonsColor: "red", forceFarsiDigits: true, sync: true };
['dateFromP', 'dateToP'].forEach(function (id) {
if (document.getElementById(id)) { kamaDatepicker(id, opts); }
});
})();
</script>
{% endblock %}
@@ -0,0 +1,291 @@
{% extends "./base.html" %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">تنظیمات سامانه پیامک</h1>
<div class="box">
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<h2 class="title is-5">اتصال به سامانه پیامک</h2>
<p class="help mb-3">
این بخش مشخص می‌کند پیامک‌ها <strong>از چه مسیری و با چه حساب کاربری</strong> به
اپراتور سپرده می‌شوند. تا وقتی «وضعیت ارسال» روشن نباشد، هیچ‌کدام از سرویس‌های
پس‌زمینه (worker) پیامکی نمی‌فرستند.
</p>
<div class="columns is-multiline">
<div class="field column is-4-desktop is-12-mobile">
<label class="label">سرویس‌دهنده</label>
<div class="control">
<div class="select is-fullwidth">
<select name="provider">
{% for value, label in provider_choices %}
<option value="{{ value }}" {% if config.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<p class="help">
<strong>مسیر فنی ارسال را تعیین می‌کند</strong> و روی بقیهٔ فیلدها اثر دارد:
<br>• <span dir="ltr">0098SMS (لینکی HTTP)</span>: ساده‌ترین حالت؛ درخواست به
آدرس <span dir="ltr">api_url</span> زده می‌شود. <strong>شناسهٔ پیامک برنمی‌گردد</strong>،
پس استعلام «تحویل به گوشی» ممکن نیست.
<br>• <span dir="ltr">0098SMS (وب‌سرویس)</span>: ارسال SOAP به
<span dir="ltr">webservice_url</span>؛ <strong>شناسهٔ پیامک (smsid)</strong> برمی‌گردد و
می‌توان وضعیت واقعی تحویل را استعلام کرد.
<br>• <span dir="ltr">HTTP عمومی</span>: برای سرویس‌های دلخواه؛ با
<span dir="ltr">api_token</span> احراز هویت می‌شود.
<br>• <span dir="ltr">Console</span>: فقط برای تست/دیباگ؛ پیامک واقعی نمی‌رود و
متن در لاگ چاپ می‌شود.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">وضعیت ارسال</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is_enabled" {% if config.is_enabled %}checked{% endif %}>
فعال باشد
</label>
</div>
<p class="help">
<strong>کلید اصلی خاموش/روشن ارسال.</strong> اگر تیک نخورد، سرویس worker هیچ
پیامکی نمی‌فرستد (ردیف‌ها در صف می‌مانند). اسکنر و صف‌کردن مستقل از این است،
پس می‌توانید پیامک‌ها را از قبل در صف بچینید و بعد ارسال را روشن کنید.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">مهلت ارتباط (ثانیه)</label>
<div class="control">
<input class="input" type="number" name="timeout_seconds" value="{{ config.timeout_seconds }}">
</div>
<p class="help">
<strong>حداکثر زمان انتظار worker برای پاسخ سرویس‌دهنده</strong> در هر تلاش.
اگر پاسخ دیرتر بیاید، آن تلاش «ناموفق» ثبت می‌شود و بر اساس سیاست تلاش مجدد
دوباره در نوبت قرار می‌گیرد. مقدار کم (مثل ۵) ممکن است در سرویس کند، ارسال‌های
سالم را هم ناموفق کند؛ مقدار زیاد (مثل ۱۲۰) در قطعی سرویس، worker را بیکار
نگه می‌دارد. پیش‌فرض ۳۰ ثانیه متعادل است.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس سرویس پیامک (URL)</label>
<div class="control">
<input class="input" type="text" name="api_url" value="{{ config.api_url }}" dir="ltr">
</div>
<p class="help">
<strong>آدرس درخواست ارسال برای providerهای «لینکی HTTP» و «HTTP عمومی».</strong>
<br>• برای 0098SMS لینکی همین آدرس است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و اگر آدرس WSDL بگذارید خطای «Invalid XML content» می‌گیرید.
<br>• برای provider وب‌سرویس، این فیلد استفاده <strong>نمی‌شود</strong> (آن یکی
<span dir="ltr">webservice_url</span> را می‌خواند).
<br>• اگر آدرس اشتباه یا در دسترس نباشد، پیامک‌ها «ناموفق» می‌شوند و در صف تلاش
مجدد می‌خورند.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس وب‌سرویس (SOAP/WSDL) — فقط provider وب‌سرویس</label>
<div class="control">
<input class="input" type="text" name="webservice_url" value="{{ config.webservice_url }}" dir="ltr">
</div>
<p class="help">
<strong>فقط وقتی provider = «وب‌سرویس» معنا دارد.</strong> هم ارسال با شناسهٔ
پیامک (<span dir="ltr">smsid</span>) و هم استعلام تحویل به گوشی از همین آدرس
انجام می‌شود. با providerهای دیگر این فیلد نادیده گرفته می‌شود. پیش‌فرض:
<span dir="ltr">https://webservice.0098sms.com/service.asmx</span>
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شمارهٔ پنل (PnlNo)</label>
<div class="control">
<input class="input" type="text" name="panel_no" value="{{ config.panel_no }}" dir="ltr">
</div>
<p class="help">
<strong>شمارهٔ اختصاصی حساب شما در وب‌سرویس</strong> (فقط provider وب‌سرویس).
اگر خالی بماند، سیستم از «شماره خط فرستنده» استفاده می‌کند. مقدار اشتباه باعث
رد شدن ارسال با خطای احراز هویت می‌شود.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">نام کاربری</label>
<div class="control">
<input class="input" type="text" name="username" value="{{ config.username }}" dir="ltr">
</div>
<p class="help">
<strong>نام کاربری حساب سامانهٔ پیامک</strong> (در همهٔ providerها به‌جز Console
لازم است). اگر اشتباه باشد، ارسال‌ها رد می‌شوند و در «خطا» صف ثبت می‌گردد.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">رمز عبور</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="{% if config.password %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
<p class="help">
<strong>رمز حساب سامانهٔ پیامک.</strong> برای امنیت، مقدار ذخیره‌شده بازخوانی
نمی‌شود؛ این کادر را <strong>خالی بگذارید</strong> تا رمز قبلی بماند و فقط وقتی
رمز عوض شده، مقدار جدید را وارد کنید.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شماره خط فرستنده</label>
<div class="control">
<input class="input" type="text" name="sender" value="{{ config.sender }}" dir="ltr">
</div>
<p class="help">
<strong>شماره یا عنوان خطی که پیامک با آن به دست مشتری می‌رسد.</strong> باید
جزو خطوط مجاز حساب شما باشد؛ در غیر این صورت اپراتور ارسال را رد می‌کند.
همچنین در provider وب‌سرویس، اگر «شمارهٔ پنل» خالی باشد همین مقدار جای آن
استفاده می‌شود.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">توکن API (فقط برای provider HTTP)</label>
<div class="control">
<input class="input" type="password" name="api_token" placeholder="{% if config.api_token %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
<p class="help">
<strong>کلید/توکن احراز هویت providerهای «HTTP عمومی».</strong> برای 0098SMS
لازم نیست. مثل رمز، خالی گذاشتن آن یعنی «بدون تغییر».
</p>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
این بخش تعیین می‌کند اسکنر دوره‌ای <strong>چه چیزی و چقدر</strong> در هر اجرا در صف
بگذارد. اسکنر هر چند دقیقه یک‌بار اجرا می‌شود و بر اساس «سررسیدها» تصمیم می‌گیرد
کدام مشتری باید یادآوری بگیرد. منطق بودجه به این شکل است:
<strong>سقف هر اجرا</strong> ← تعیین می‌کند کلاً چند مورد صف شود؛
<strong>خط شروع</strong> ← موارد قدیمی‌تر از آن را کنار می‌گذارد؛
<strong>اجازهٔ موارد قدیمی + سقف آن</strong> ← تصمیم می‌گیرد از آن موارد کنارگذاشته‌شده
چند تا (اگر لازم بود) ارسال شود.
</p>
<div class="columns is-multiline">
<div class="field column is-6-desktop is-12-mobile">
<label class="label">تاریخ شروع دوره</label>
<div class="control">
<input class="input" type="text" name="scan_since"
value="{{ scan_since_jalali }}"
placeholder="مثلاً 1403/07/18 — خالی = همین حالا" dir="ltr">
</div>
<p class="help">
<strong>خط جداکنندهٔ «موارد جدید» از «موارد قدیمی».</strong>
هر مورد (چک، نسیه، بازدید) یک «لحظهٔ باز شدن پنجرهٔ یادآوری» دارد. اگر این
لحظه <strong>از این تاریخ به بعد</strong> باشد، مورد «جدید» است و آزادانه صف
می‌شود؛ اگر <strong>قبل از</strong> آن باشد، «قدیمی» (backlog) حساب می‌شود.
<br>• هدف اصلی: در نخستین راه‌اندازی، هزاران مشتری قدیمیِ واجد شرط یک‌باره پیامک
نگیرند.
<br>• قالب: <span dir="ltr">YYYY/MM/DD</span> شمسی — خالی = همین حالا.
<br>• عوض‌کردن آن به گذشته، مشتریان قدیمی‌تر را «جدید» می‌کند و ممکن است سیل پیامک
بسازد؛ با احتیاط تغییر دهید.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر پیامک هر اسکنر در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="max_per_run" value="{{ config.max_per_run }}">
</div>
<p class="help">
<strong>سقف کل صف‌کردن در یک اجرا — به‌ازای هر اسکنر جداگانه.</strong>
<br>• «هر اسکنر» یعنی این عدد برای هر نوع یادآوری مستقل اعمال می‌شود
(تأمین موجودی چک، سررسید نسیه، بازدید دورهای)؛ پس اگر ۱۰۰ باشد و هر سه اسکنر
واجد شرط باشند، در یک اجرا حداکثر ۱۰۰ + ۱۰۰ + ۱۰۰ = ۳۰۰ ردیف صف می‌شود.
<br>• شمارش از مجموع «جدیدها + موارد قدیمی» است، نه فقط جدیدها.
<br>• <strong>۰ = بی‌نهایت</strong> (هیچ سقفی اعمال نمی‌شود).
<br>• چون اسکنر دوره‌ای اجرا می‌شود، سقف باعث «دسته‌دسته» شدن ارسال‌های انبوه
می‌شود (بقیه در اجراهای بعدی می‌آیند) و از ریختن یک‌بارهٔ صدها پیامک روی ارسال
جلوگیری می‌کند.
<br>• نکته: این سقف‌ها روی «صف‌کردن» اثر دارند، نه سرعت ارسال واقعی؛ سرعت ارسال
به «فاصلهٔ بین هر پیامک» بستگی دارد.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">فاصلهٔ بین هر پیامک (ثانیه)</label>
<div class="control">
<input class="input" type="number" min="0" name="send_interval_seconds" value="{{ config.send_interval_seconds }}">
</div>
<p class="help">
<strong>مکث سرویس worker پس از هر ارسال</strong> تا اپراتور زیر بار زیاد نرود.
مثال: با مقدار ۳، ارسال ۱۰۰ پیامک حدود ۵ دقیقه طول می‌کشد. مقدار ۰ یعنی بدون
مکث (سریع ولی پرخطر — ممکن است سرویس‌دهنده محدودتان کند).
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">اجازهٔ ارسال موارد قدیمی</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="allow_backlog" {% if config.allow_backlog %}checked{% endif %}>
فعال باشد
</label>
</div>
<p class="help">
<strong>دربِ موارد «قدیمی‌تر از تاریخ شروع دوره».</strong> به‌صورت پیش‌فرض بسته
است. تا وقتی بسته باشد، موارد قدیمی <strong>هرگز</strong> صف نمی‌شوند (حتی اگر
سقف باقی باشد). اگر لازم بود این موارد هم پردازش شوند، آن را روشن کنید و مقدار
«حداکثر موارد قدیمی در هر اجرا» را تعیین کنید.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر موارد قدیمی در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="backlog_max" value="{{ config.backlog_max }}">
</div>
<p class="help">
<strong>سقف ارسال موارد قدیمی در هر اجرا، به‌ازای هر اسکنر.</strong>
<br>• فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن باشد اثر دارد.
<br>• صفر = هیچ مورد قدیمی ارسال نمی‌شود (حتی با روشن‌بودن کلید).
<br>• این سقف <strong>در کنار</strong> «حداکثر پیامک هر اسکنر» اعمال می‌شود؛ پس
ممکن است ابتدا سقف کل (max_per_run) پر شود.
<br>• برای پاک‌کردن تدریجی انبار قدیمی، عدد کوچکی مثل ۵۰ بگذارید تا در هر اجرا
دسته‌ای ارسال شود.
</p>
</div>
</div>
<div class="buttons">
<button class="button is-success" type="submit">ذخیره تنظیمات</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">ارسال پیامک آزمایشی</h2>
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<input type="hidden" name="action" value="test">
<div class="field">
<label class="label">متن پیامک آزمایشی</label>
<div class="control">
<textarea class="textarea" name="test_message" rows="3">{{ config.test_message }}</textarea>
</div>
<p class="help">این متن ذخیره می‌شود و در ارسال‌های بعدی هم استفاده خواهد شد.</p>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="text" name="test_phone" placeholder="مثلاً 09120000000" dir="ltr">
</div>
<div class="control">
<button class="button is-info" type="submit">ارسال آزمایشی</button>
</div>
</div>
</form>
<p class="help">پیامک آزمایشی در صف قرار می‌گیرد و توسط worker ارسال می‌شود.</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,455 @@
{% extends "./base.html" %}
{% block bodyPage %}
<style>
{% verbatim %}
/* --- صفحهٔ قالب‌های پیامک: استایل‌ها با پیشوند smsx و محدود به همین صفحه --- */
/* هیچ عنصری از این صفحه نباید از کادر خودش بیرون بزند؛ ریشهٔ به‌هم‌ریختگی‌ها
معمولاً همین box-sizing است (width:100% + padding). */
.smsx, .smsx *, .smsx *::before, .smsx *::after { box-sizing: border-box; }
.smsx { max-width: 60rem; margin-inline: auto; }
.smsx img, .smsx textarea, .smsx input, .smsx button { max-width: 100%; }
.smsx-head { display: flex; flex-wrap: wrap; gap: .75rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.smsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
/* ---------- کارت هر نوع پیامک ---------- */
.smsx-card { background: #fff; border: 1px solid #e8e8e8; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: .9rem; overflow: hidden; }
.smsx-card[open] { box-shadow: 0 6px 18px rgba(0,0,0,.07); border-color: #d9d9d9; }
.smsx-card.is-off .smsx-summary__title { color: #9a9a9a; }
.smsx-card.is-dirty { border-color: #ffdd57; }
.smsx-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: .75rem; align-items: center; padding: .9rem 1rem; cursor: pointer; list-style: none; }
.smsx-summary::-webkit-details-marker { display: none; }
.smsx-summary:hover { background: #fafafa; }
.smsx-summary:focus-visible { outline: 2px solid #3273dc; outline-offset: -2px; }
.smsx-summary__icon { font-size: 1.35rem; line-height: 1; }
.smsx-summary__main { min-width: 0; }
.smsx-summary__title { display: block; font-weight: 600; color: #363636; overflow-wrap: anywhere; }
.smsx-summary__sub { display: block; font-size: .78rem; color: #7a7a7a; line-height: 1.7; overflow-wrap: anywhere; }
.smsx-summary__meta { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.smsx-summary__chev { flex: 0 0 auto; width: .55rem; height: .55rem; border-right: 2px solid #b5b5b5; border-bottom: 2px solid #b5b5b5; transform: rotate(45deg); transition: transform .18s; }
.smsx-card[open] .smsx-summary__chev { transform: rotate(-135deg); }
/* ---------- بدنهٔ فرم ---------- */
.smsx-body { padding: 1rem; border-top: 1px solid #f0f0f0; }
/* گرید سه‌ستونی مقاوم: ستون عنوان کشسان، دو ستون دیگر به‌قدر محتوا */
.smsx-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) max-content; gap: .9rem 1rem; align-items: start; }
.smsx-cell { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
.smsx-label { display: block; margin: 0; font-size: .8rem; font-weight: 600; color: #4a4a4a; line-height: 1.6; overflow-wrap: anywhere; }
.smsx-cell--switch .smsx-label { white-space: nowrap; }
.smsx-note { margin-top: .75rem; margin-bottom: 0; line-height: 1.9; }
/* کلید فعال/غیرفعال */
.smsx-switch { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; min-height: 2.5rem; }
.smsx-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.smsx-switch__track { position: relative; flex: 0 0 auto; width: 46px; height: 26px; border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.smsx-switch__track::after { content: ""; position: absolute; top: 3px; inset-inline-start: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.smsx-switch input:checked + .smsx-switch__track { background: #48c774; }
.smsx-switch input:checked + .smsx-switch__track::after { transform: translateX(-20px); }
.smsx-switch input:focus-visible + .smsx-switch__track { outline: 2px solid #3273dc; outline-offset: 2px; }
.smsx-switch__text { font-size: .85rem; color: #4a4a4a; white-space: nowrap; }
/* ---------- کادر نوشتن متن ---------- */
.smsx-editor { margin-top: 1rem; border: 1px solid #e2e2e2; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .15s, box-shadow .15s; }
.smsx-editor:focus-within { border-color: #3273dc; box-shadow: 0 0 0 3px rgba(50,115,220,.12); }
/* padding افقی سر/پا هم‌اندازهٔ padding خود textarea (1rem) تا متن و برچسب هم‌تراز بمانند */
.smsx-editor__head { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center; justify-content: space-between; padding: .65rem 1rem; background: #fbfbfb; border-bottom: 1px solid #eee; }
.smsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.smsx-chip { display: inline-block; font-size: .75rem; line-height: 1.6; padding: .35rem .65rem; border-radius: 999px; border: 1px solid #dbdbdb; background: #fff; color: #4a4a4a; cursor: pointer; direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
.smsx-chip:hover { border-color: #3273dc; color: #3273dc; background: #f5f9ff; }
.smsx-chip:active { transform: translateY(1px); }
/* textarea دقیقاً به اندازهٔ کادر؛ بدون border/padding سرکش */
.smsx-editor textarea.smsx-textarea {
display: block; width: 100%; max-width: 100%; margin: 0;
border: 0; border-radius: 0; box-shadow: none; background: transparent;
padding: .85rem 1rem; font-size: 1rem; line-height: 1.9; font-family: inherit;
resize: vertical; overflow-y: auto;
unicode-bidi: plaintext; text-align: start; overflow-wrap: anywhere;
}
.smsx-editor textarea.smsx-textarea:focus { border: 0; box-shadow: none; outline: none; }
.smsx-editor__foot { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; padding: .5rem 1rem; border-top: 1px solid #f0f0f0; background: #fbfbfb; font-size: .78rem; color: #7a7a7a; }
.smsx-dot { color: #dbdbdb; }
.smsx-metric { font-variant-numeric: tabular-nums; }
.smsx-metric.is-ok { color: #257942; }
.smsx-metric.is-warn { color: #b8860b; }
.smsx-metric.is-bad { color: #cc0f35; font-weight: 600; }
.smsx-warn { color: #cc0f35; overflow-wrap: anywhere; }
/* ---------- پیش‌نمایش ---------- */
.smsx-preview { margin-top: 1rem; }
.smsx-preview__label { display: block; font-size: .8rem; font-weight: 600; color: #4a4a4a; margin-bottom: .4rem; }
.smsx-bubble { background: #ecf7ee; border: 1px solid #d5ecda; border-radius: 14px 14px 4px 14px; padding: .75rem .95rem; line-height: 1.9; font-size: .9rem; white-space: pre-wrap; overflow-wrap: anywhere; min-height: 2.6rem; }
.smsx-preview__hint { margin: .4rem 0 0; font-size: .75rem; line-height: 1.8; }
.smsx-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-top: 1rem; }
.smsx-dirty { font-size: .8rem; color: #b8860b; }
.smsx-help { margin-bottom: 1rem; background: #f5f9ff; border: 1px solid #dbe9ff; border-radius: 10px; padding: .7rem .95rem; }
.smsx-help > summary { cursor: pointer; font-size: .85rem; color: #3273dc; font-weight: 600; }
.smsx-help .smsx-chips { margin-top: .6rem; }
.smsx-help .smsx-chip { cursor: default; white-space: normal; }
/* ---------- موبایل ---------- */
@media (max-width: 768px) {
.smsx-body { padding: .85rem; }
.smsx-summary { grid-template-columns: auto minmax(0, 1fr) auto; padding: .8rem .85rem; row-gap: .5rem; }
.smsx-summary__meta { grid-column: 1 / -1; }
/* همهٔ فیلدها زیر هم؛ دیگر هیچ چیز به هم نمی‌چسبد */
.smsx-grid { grid-template-columns: minmax(0, 1fr); }
.smsx-cell--switch .smsx-label { white-space: normal; }
.smsx-editor textarea.smsx-textarea { font-size: 16px; } /* جلوگیری از زوم خودکار iOS */
.smsx-actions .button { width: 100%; }
}
{% endverbatim %}
</style>
<div class="container mt-5" dir="rtl">
<div class="smsx">
<div class="smsx-head">
<div>
<h1 class="title is-4 mb-1">قالب‌های پیامک</h1>
<p class="subtitle is-6 has-text-grey mb-0">
متن هر نوع پیامک و زمان ارسال آن را اینجا تعیین کنید. برای ویرایش، روی هر کارت بزنید.
</p>
</div>
<div class="smsx-head__actions">
<button type="button" class="button is-small is-light" data-smsx-expand-all>باز کردن همه</button>
<button type="button" class="button is-small is-light" data-smsx-collapse-all>بستن همه</button>
</div>
</div>
<details class="smsx-help">
<summary>متغیرهای قابل استفاده در متن پیامک</summary>
<p class="help mb-0">کافی است روی هر متغیر بزنید تا در کادر متن همان پیامک درج شود.</p>
<div class="smsx-chips">
{% for token, desc in variables %}
<span class="smsx-chip" title="{{ desc }}">{{ token }} <span class="has-text-grey">{{ desc }}</span></span>
{% endfor %}
</div>
</details>
{{ smsx_config|json_script:"smsx-config" }}
{% for row in rows %}
<details class="smsx-card" id="type-{{ row.code }}" {% if forloop.first %}open{% endif %} data-smsx-card>
<summary class="smsx-summary">
<span class="smsx-summary__icon" aria-hidden="true">{{ row.icon }}</span>
<span class="smsx-summary__main">
<span class="smsx-summary__title">{{ row.label }}</span>
<span class="smsx-summary__sub">
{{ row.when }}{% if row.timing_value is not None %} · {{ row.timing_label }}: {{ row.timing_value }}{% endif %}
</span>
</span>
<span class="smsx-summary__meta">
<span class="tag is-light" data-smsx-badge>—</span>
{% if not row.template.is_enabled %}
<span class="tag is-warning is-light">غیرفعال</span>
{% endif %}
{% if row.template.auto_send %}
<span class="tag is-success is-light">خودکار</span>
{% endif %}
</span>
<span class="smsx-summary__chev" aria-hidden="true"></span>
</summary>
<form class="smsx-body" method="post" action="{% url 'admin_panel:sms_template_edit' row.template.id %}">
{% csrf_token %}
<div class="smsx-grid">
<div class="smsx-cell">
<label class="smsx-label" for="title-{{ row.code }}">عنوان</label>
<input id="title-{{ row.code }}" class="input" type="text" name="title" value="{{ row.template.title }}">
</div>
{% if row.timing_value is not None %}
<div class="smsx-cell">
<label class="smsx-label" for="timing-{{ row.code }}">{{ row.timing_label }}</label>
<input id="timing-{{ row.code }}" class="input" type="number" inputmode="numeric" min="0"
name="timing_value" value="{{ row.timing_value|default_if_none:'' }}">
</div>
{% endif %}
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">وضعیت ارسال</span>
<label class="smsx-switch">
<input type="checkbox" name="is_enabled" {% if row.template.is_enabled %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.is_enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</label>
</div>
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">ارسال</span>
<label class="smsx-switch" title="روشن = ارسال خودکار بدون صف تأیید مدیر / خاموش = نیازمند تأیید مدیر">
<input type="checkbox" name="auto_send" {% if row.template.auto_send %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.auto_send %}خودکار{% else %}با تأیید مدیر{% endif %}</span>
</label>
</div>
</div>
{% if row.timing.note %}
<p class="help smsx-note">{{ row.timing.note }}</p>
{% endif %}
<div class="smsx-editor">
<div class="smsx-editor__head">
<label class="smsx-label" for="text-{{ row.code }}">متن پیامک</label>
<div class="smsx-chips" role="group" aria-label="درج متغیر در متن">
{% for token, desc in variables %}
<button type="button" class="smsx-chip" data-smsx-insert="{{ token }}"
title="{{ desc }}" aria-label="درج {{ token }} — {{ desc }}">{{ token }}</button>
{% endfor %}
</div>
</div>
<textarea id="text-{{ row.code }}" class="textarea smsx-textarea" name="text" rows="6"
data-smsx-text aria-describedby="meta-{{ row.code }}"
placeholder="متن پیامک را اینجا بنویسید…">{{ row.template.text }}</textarea>
<div class="smsx-editor__foot" id="meta-{{ row.code }}">
<span class="smsx-metric" data-smsx-counter>—</span>
<span class="smsx-dot" aria-hidden="true">•</span>
<span class="smsx-metric" data-smsx-segments>—</span>
<span class="smsx-warn" data-smsx-warn hidden></span>
</div>
</div>
<div class="smsx-preview">
<span class="smsx-preview__label">پیش‌نمایش (با دادهٔ نمونه)</span>
<div class="smsx-bubble" data-smsx-preview>{{ row.preview }}</div>
<p class="help smsx-preview__hint">
مقادیر نمونه فقط برای نمایش است؛ هنگام ارسال، اطلاعات واقعی همان مشتری جای متغیرها می‌نشیند.
</p>
</div>
<div class="smsx-actions">
<button class="button is-success" type="submit">ذخیره</button>
<span class="smsx-dirty" data-smsx-dirty hidden>● تغییرات ذخیره‌نشده</span>
</div>
</form>
</details>
{% empty %}
<div class="notification is-warning">هیچ قالبی تعریف نشده است.</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block jsEnd %}
<script>
{% verbatim %}
(function () {
"use strict";
var configEl = document.getElementById("smsx-config");
if (!configEl) return;
var CFG = JSON.parse(configEl.textContent);
var TOKENS = CFG.tokens || [];
var SAMPLE = CFG.sample || {};
var TOKEN_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
// GSM-7 پایه (یک نویسه) و نویسه‌های افزودنی (دو نویسه)
var GSM_BASIC = {};
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n\r".split("").forEach(function (c) { GSM_BASIC[c] = true; });
var GSM_EXT = {};
["^", "{", "}", "[", "]", "~", "|", "\\", "€"].forEach(function (c) { GSM_EXT[c] = true; });
function toFa(value) {
return String(value).replace(/\d/g, function (d) { return "۰۱۲۳۴۵۶۷۸۹"[d]; });
}
// شمارش نویسه و بخش‌های پیامک: GSM-7 ⇒ ۱۶۰/۱۵۳، یونیکد ⇒ ۷۰/۶۷
function measure(text) {
var chars = Array.from(text);
var gsm = true, len = 0;
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (GSM_BASIC[c]) { len += 1; }
else if (GSM_EXT[c]) { len += 2; }
else { gsm = false; break; }
}
if (!gsm) len = chars.length;
var single = gsm ? 160 : 70, multi = gsm ? 153 : 67;
var parts = len === 0 ? 0 : (len <= single ? 1 : Math.ceil(len / multi));
return { len: len, parts: parts, gsm: gsm };
}
function fillSample(text) {
return text.replace(TOKEN_RE, function (match, name) {
return Object.prototype.hasOwnProperty.call(SAMPLE, name) ? SAMPLE[name] : "";
});
}
function unknownTokens(text) {
var found, seen = {}, out = [];
while ((found = TOKEN_RE.exec(text)) !== null) {
var name = "{" + found[1] + "}";
if (TOKENS.indexOf(name) === -1 && !seen[name]) { seen[name] = true; out.push(name); }
}
TOKEN_RE.lastIndex = 0;
return out;
}
function cardOf(el) { return el ? el.closest("[data-smsx-card]") : null; }
function refresh(card) {
var ta = card.querySelector("[data-smsx-text]");
if (!ta) return;
var text = ta.value;
var shown = fillSample(text);
var info = measure(shown);
var counter = card.querySelector("[data-smsx-counter]");
var segments = card.querySelector("[data-smsx-segments]");
var warn = card.querySelector("[data-smsx-warn]");
var preview = card.querySelector("[data-smsx-preview]");
var badge = card.querySelector("[data-smsx-badge]");
counter.textContent = toFa(info.len) + " نویسه";
if (info.parts === 0) {
segments.textContent = "متن خالی است";
segments.className = "smsx-metric";
} else {
segments.textContent = toFa(info.parts) + (info.parts === 1 ? " پیامک" : " پیامک (چندبخشی)");
segments.className = "smsx-metric " + (info.parts <= 1 ? "is-ok" : (info.parts <= 3 ? "is-warn" : "is-bad"));
}
if (badge) badge.textContent = info.parts === 0 ? "—" : toFa(info.parts) + " پیامک";
var unknown = unknownTokens(text);
if (unknown.length) {
warn.hidden = false;
warn.textContent = "متغیر ناشناخته: " + unknown.join("، ") + " — خالی رندر می‌شود.";
} else {
warn.hidden = true;
warn.textContent = "";
}
if (preview) preview.textContent = shown;
}
var DIRTY = false;
function markDirty(card) {
if (!card || card.classList.contains("is-dirty")) return;
card.classList.add("is-dirty");
var flag = card.querySelector("[data-smsx-dirty]");
if (flag) flag.hidden = false;
DIRTY = true;
}
function insertAtCursor(ta, token) {
ta.focus();
var value = ta.value;
var start = ta.selectionStart == null ? value.length : ta.selectionStart;
var end = ta.selectionEnd == null ? value.length : ta.selectionEnd;
var before = value.slice(0, start);
var prefix = (before && !/\s$/.test(before)) ? " " : "";
ta.value = before + prefix + token + value.slice(end);
var pos = start + prefix.length + token.length;
ta.setSelectionRange(pos, pos);
}
document.addEventListener("click", function (event) {
// بستن اعلان‌ها (Bulma JS بارگذاری نشده است).
var dismiss = event.target.closest(".notification .delete");
if (dismiss) {
var box = dismiss.closest(".notification");
if (box) box.remove();
return;
}
var insert = event.target.closest("[data-smsx-insert]");
if (insert) {
event.preventDefault();
var card = cardOf(insert);
var ta = card && card.querySelector("[data-smsx-text]");
if (!ta) return;
insertAtCursor(ta, insert.getAttribute("data-smsx-insert"));
refresh(card);
markDirty(card);
return;
}
if (event.target.closest("[data-smsx-expand-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = true; refresh(d); });
return;
}
if (event.target.closest("[data-smsx-collapse-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = false; });
}
});
document.addEventListener("input", function (event) {
var card = cardOf(event.target);
if (!card) return;
if (event.target.matches("[data-smsx-text]")) {
refresh(card);
markDirty(card);
} else if (event.target.matches('input[name="title"], input[name="timing_value"]')) {
markDirty(card);
}
});
document.addEventListener("change", function (event) {
var input = event.target;
if (!input.matches('input[name="is_enabled"], input[name="auto_send"]')) return;
var card = cardOf(input);
if (!card) return;
markDirty(card);
if (input.name === "is_enabled") card.classList.toggle("is-off", !input.checked);
// متن سوئیچِ همان کلید به‌روز می‌شود (نه اولین سوئیچ کارت).
var sw = input.closest(".smsx-switch");
var label = sw && sw.querySelector(".smsx-switch__text");
if (label) {
label.textContent = input.name === "is_enabled"
? (input.checked ? "فعال" : "غیرفعال")
: (input.checked ? "خودکار" : "با تأیید مدیر");
}
});
// «toggle» رویداد حباب‌شو نیست؛ با capture گرفته می‌شود.
document.addEventListener("toggle", function (event) {
if (event.target.matches && event.target.matches("[data-smsx-card]") && event.target.open) {
refresh(event.target);
}
}, true);
document.addEventListener("submit", function () { DIRTY = false; });
window.addEventListener("beforeunload", function (event) {
if (!DIRTY) return;
event.preventDefault();
event.returnValue = "";
});
function init() {
document.querySelectorAll("[data-smsx-card]").forEach(function (card) {
var toggle = card.querySelector('input[name="is_enabled"]');
if (toggle) card.classList.toggle("is-off", !toggle.checked);
refresh(card);
});
// پس از ذخیره، همان کارت باز و در نمای دید باقی می‌ماند (لنگر #type-…).
var hash = window.location.hash;
if (hash.indexOf("#type-") === 0) {
var target = document.getElementById(hash.slice(1));
if (target) {
document.querySelectorAll("[data-smsx-card][open]").forEach(function (d) {
if (d !== target) d.open = false;
});
target.open = true;
refresh(target);
target.scrollIntoView({ block: "start" });
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
{% endverbatim %}
</script>
{% endblock %}
+39 -1
View File
@@ -1,5 +1,6 @@
from django import template
from persiantools.jdatetime import JalaliDate
from django.utils import timezone
from persiantools.jdatetime import JalaliDate, JalaliDateTime
import datetime
register = template.Library()
@@ -8,8 +9,45 @@ def toJalali(value):
if(value):
return JalaliDate(value).strftime("%Y/%m/%d")
def toJalaliDT(value):
"""تاریخ و ساعت به شمسی، در تایم‌زون محلی پروژه."""
if not value:
return ""
if timezone.is_aware(value):
value = timezone.localtime(value)
return JalaliDateTime(value).strftime("%Y/%m/%d %H:%M")
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
def row_for(mapping, key):
"""مقدار یک نگاشت با کلید دلخواه (برای دسترسی به دیکشنری در قالب).
``{{ appointment_rows|row_for:customer.id }}`` ⇒ مقدار مربوط به همان مشتری،
یا ``None`` اگر نبود. (جایگزین سبکِ ``.get()`` در قالب‌های Django.)
"""
if not mapping:
return None
try:
return mapping.get(key)
except AttributeError:
return None
@register.simple_tag
def customer_phones(customer):
"""شماره‌های مشتری برای ستون اطلاعات مشتری.
قبلاً این داده فقط از context view می‌آمد و صفحاتی که آن را پاس نمی‌دادند
«شماره‌ای ثبت نشده» نشان می‌دادند؛ حالا از خودِ شیء ``customer`` استخراج
می‌شود تا هر صفحه‌ای که این ستون را رندر می‌کند یکسان باشد.
"""
if not getattr(customer, 'pk', None):
return []
from admin_panel.sms.phones import customer_phone_entries
return customer_phone_entries(customer)
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
register.filter('toJalaliDT', toJalaliDT)
register.filter('row_for', row_for)
+17
View File
@@ -37,6 +37,7 @@ urlpatterns = [
path('customer/<int:c_id>/nazar/', views.customerNazar, name='customerNazar'),
path('customer/<int:c_id>/action/new', views.customerNewAction, name='customerNewAction'),
path('customer/<int:c_id>/action/<int:action_id>/edit', views.customerEditAction, name='customerEditAction'),
path('customer/<int:c_id>/action/<int:action_id>/delete', views.customerActionDelete, name='customerActionDelete'),
path('customer/<int:c_id>/del', views.customerDel, name='customerDel'),
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
@@ -80,4 +81,20 @@ urlpatterns = [
path('addSarresid/<int:cs_id>', views.addSarresid, name='addSarresid'),
path('pardakhtNesiye/<int:cs_id>', views.pardakhtNesiye, name='pardakhtNesiye'),
# === مدیریت پیامک ===
path('customer/<int:c_id>/sms/', views.customer_sms_history, name='customerSmsHistory'),
path('sms/settings', views.sms_settings, name='sms_settings'),
path('sms/templates', views.sms_templates, name='sms_templates'),
path('sms/template/<int:t_id>/edit', views.sms_template_edit, name='sms_template_edit'),
path('sms/customer/<int:c_id>/toggle', views.sms_customer_toggle, name='sms_customer_toggle'),
path('sms/customer/<int:c_id>/primary', views.sms_primary_toggle, name='sms_primary_toggle'),
path('sms/customer/<int:c_id>/send', views.sms_send_custom, name='sms_send_custom'),
path('sms/queue', views.sms_queue, name='sms_queue'),
path('sms/report', views.sms_report, name='sms_report'),
path('sms/queue/bulk', views.sms_queue_bulk, name='sms_queue_bulk'),
path('sms/queue/empty', views.sms_queue_empty, name='sms_queue_empty'),
path('sms/queue/<int:q_id>/approve', views.sms_queue_approve, name='sms_queue_approve'),
path('sms/queue/<int:q_id>/delete', views.sms_queue_delete, name='sms_queue_delete'),
path('sms/queue/<int:q_id>/retry', views.sms_queue_retry, name='sms_queue_retry'),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
-2896
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
"""
پکیج ویوهای اپ admin_panel.
این پکیج جایگزین فایل تک‌پارچهٔ سابق `admin_panel/views.py` شده است. هر ویو در ماژول
دامنه‌ای مربوط به خود قرار گرفته و در اینجا همهٔ آن‌ها re-export می‌شوند تا مصرف‌کنندهٔ
فعلی (`from . import views` در urls.py) بدون هیچ تغییری به `views.X` دسترسی داشته باشد.
"""
from ._helpers import ( # noqa: F401
logger,
InventoryException,
_safe_int,
_parse_jalali_date,
_parse_timestamp,
)
from .auth import * # noqa: F401,F403
from .customers import * # noqa: F401,F403
from .factors import * # noqa: F401,F403
from .store import * # noqa: F401,F403
from .finance import * # noqa: F401,F403
from .actions import * # noqa: F401,F403
from .cheque_inquiry import * # noqa: F401,F403
from .sms import * # noqa: F401,F403
+67
View File
@@ -0,0 +1,67 @@
"""توابع و ابزارهای مشترک لایهٔ ویوها."""
import datetime
import logging
from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from persiantools import digits
logger = logging.getLogger(__name__)
class InventoryException(Exception):
"""خطای اختصاصی برای کنترل موجودی انبار"""
pass
def _safe_int(value, default=0):
"""تابع امن برای تبدیل مقادیر دریافتی از جاوااسکریپت به عدد"""
if value is None:
return default
try:
clean_val = str(value).strip()
return int(clean_val) if clean_val else default
except (ValueError, TypeError):
return default
def _parse_jalali_date(date_str):
"""تبدیل امن تاریخ ارسالی از pDatepicker به میلادی"""
if not date_str or not str(date_str).strip():
return None
try:
clean_date = digits.fa_to_en(digits.ar_to_fa(str(date_str).strip()))
y, m, d = map(int, clean_date.split("/"))
return JalaliDate(y, m, d).to_gregorian()
except Exception as e:
logger.warning(f"Error parsing Jalali date '{date_str}': {e}")
return None
def _parse_phone_rows(request):
"""آرایه‌های شمارهٔ فرم مشتری را به ردیف‌های معتبر تبدیل می‌کند.
خروجی یک سه‌گانه است: ردیف‌های آمادهٔ ذخیره، آرایهٔ خام شماره‌ها، و ایندکس ردیف‌های
«اصلی». ایندکس‌ها عمداً نسبت به *آرایهٔ خام* برگردانده می‌شوند نه ردیف‌های فیلترشده؛
وگرنه حذف ردیف‌های خالی، ایندکس‌ها را جابه‌جا می‌کرد و تیک «اصلی» به شمارهٔ اشتباه می‌خورد.
"""
titles = request.POST.getlist('phone_titles')
phones = request.POST.getlist('phones')
primary = {int(i) for i in request.POST.getlist('phone_primary') if str(i).isdigit()}
rows = [
{'title': title.strip() or 'تلفن', 'phone': phone.strip()}
for title, phone in zip(titles, phones)
if phone.strip()
]
return rows, phones, primary
def _parse_timestamp(ts_value):
"""تبدیل یونیکس تایم‌استمپ‌های ارسالی از جاوااسکریپت"""
if not ts_value:
return None
try:
# تبدیل میلی‌ثانیه جاوااسکریپت به ثانیه پایتون
return datetime.datetime.fromtimestamp(int(ts_value) / 1000.0, tz=timezone.get_current_timezone())
except (ValueError, TypeError):
return None
+516
View File
@@ -0,0 +1,516 @@
import datetime
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse
from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
ActionDeleteError, as_aware,
cancel_pending_sms_for_action, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
validate_action_payload)
@login_required(login_url='/panel/p_login/')
def customerAction(request, c_id):
# استفاده از get_object_or_404 برای امنیت بیشتر
customer = get_object_or_404(Customer, id=c_id)
# دریافت پارامترهای فیلتر از URL (پیش‌فرض: all)
filter_type = request.GET.get('actionType', 'all')
filter_status = request.GET.get('status', 'all')
# کوئری پایه برای اقدامات این مشتری.
# اقدام‌های حذف‌شده (soft-delete) به‌طور پیش‌فرض نمایش داده نمی‌شوند؛ با
# فیلتر صریح `status=cancell` می‌توان آن‌ها را دید (برای حسابرسی).
actions = Action.objects.filter(customer=customer)
if filter_status != 'cancell':
actions = actions.filter(deleted_at__isnull=True)
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
if filter_type != 'all':
actions = actions.filter(actionType=filter_type)
# اعمال فیلتر بر اساس وضعیت (انجام شده، در انتظار و ...)
if filter_status != 'all':
actions = actions.filter(status=filter_status)
# مرتب‌سازی نزولی بر اساس تاریخ ایجاد
actions = actions.order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {
'customer': customer,
'actions': actions,
'allUsers': allUsers,
# ارسال مقادیر فیلتر به قالب برای حفظ وضعیت انتخاب‌شده در دراپ‌داون‌ها
'filter_type': filter_type,
'filter_status': filter_status
}
return render(request, 'admin_panel/customerAction.html', data)
@login_required(login_url='/panel/p_login/')
def customerNazar(request ,c_id):
customer = Customer.objects.get(id=c_id)
actions = Action.objects.filter(
customer=customer, nazarDT__isnull=False, deleted_at__isnull=True,
).order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
def _parse_form_datetime(raw):
"""timestamp میلی‌ثانیه‌ای فرم → دیتاتایم aware (یا ``None``).
کد قبلی ``datetime.datetime.fromtimestamp`` (naive) می‌ساخت که هنگام ذخیره
با ``USE_TZ=True`` هشدار ``RuntimeWarning`` می‌داد؛ اینجا صریح aware می‌شود.
"""
try:
return as_aware(datetime.datetime.fromtimestamp(int(raw) / 1e3))
except (TypeError, ValueError, OverflowError, OSError):
return None
def _action_payload_from_request(request):
"""استخراج و نرمال‌سازی ورودی فرم اقدام (مشترک ایجاد و ویرایش)."""
is_done = bool(request.POST.get('is_done'))
return {
'actionType': (request.POST.get('actionType') or '').strip(),
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
# تأیید صریح کاربر برای «عقب بردن» تاریخ قرار دستی.
'force_hold': bool(request.POST.get('force_hold')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
'is_done': is_done,
}
def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد:
* برای اقدام نوع «هماهنگی»:
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو، مگر تأیید کاربر)
- «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- لغو شده ⇒ (در ایجاد) پاک می‌شود
* برای **هر اقدام دیگری** (از جمله «بازدید»):
- در حالت **ایجاد**، این اقدام آخرین فعالیت مشتری است ⇒ قرار دستی پاک
می‌شود (سناریوی گزارش‌شده: ثبت بازدید پس از هماهنگی).
- در حالت **ویرایش**، رکوردی تاریخی است و ممکن است قرارِ فعلی از یک
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
خروجی (دیکشنری، برای بازخورد شفاف به کاربر)::
{'ok': bool, 'reason': 'written'|'default'|'forward_only'|'cleared'|'',
'current': datetime|None, 'target': datetime|None}
``reason='forward_only'`` یعنی تاریخ درخواستی از قرار فعلی عقب‌تر است و
نوشته **نشد**؛ لایهٔ ویو باید همین را با تاریخ‌ها به کاربر نشان بدهد و اگر
خودش تأیید کرد، با ``force_hold=True`` دوباره صدا بزند.
"""
result = {'ok': True, 'reason': '', 'current': None, 'target': None,
'force_allowed': not is_new}
# «عقب‌بردن قرار» فقط در ویرایش مجاز است. در ایجادِ وظیفهٔ تازه، کاربر قصد
# اصلاح قرارِ موجود را ندارد و اجازه دادن به آن می‌تواند قولِ قبلیِ ثبت‌شده
# (شاید توسط کارشناس دیگر) را بی‌آنکه کسی بخواهد کوتاه کند.
force_hold = bool(force_hold) and not is_new
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL:
if is_new:
result['ok'] = bool(clear_visit_hold(customer_obj))
result['reason'] = 'cleared' if result['ok'] else ''
return result
raw = payload.get('holdRaw')
if raw is not None:
target = normalize_hold_until(raw)
reason = 'written'
elif payload['status'] == Action.Status.FINISH:
target = default_hold_until()
reason = 'default'
else:
return result
current = as_aware(customer_obj.visit_hold_until)
result['current'] = current
result['target'] = target
written = set_visit_hold(customer_obj, target, force=force_hold)
result['ok'] = written
result['reason'] = reason if written else 'forward_only'
return result
# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمی‌زد).
_ACTION_EVENT_SMS = {
Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
}
def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
"""هم‌راستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
* نوع جدید **رویدادی** است (بازدید/نصب):
- اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
می‌شود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
- اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
نمی‌شود، پس ویرایش‌های پیاپی اسپم تولید نمی‌کنند.
* نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
لغو می‌شود (کاربر عملاً بازدید/نصب را پس گرفته است).
خروجی: تعداد پیامک‌های ساخته‌شده.
"""
current = _ACTION_EVENT_SMS.get(action.actionType)
previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
# نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
# وگرنه هر دو پیامک در صف می‌مانند.
if previous is not None and previous_type != action.actionType:
cancel_pending_sms_for_action(
customer, action_id=action.pk, sms_type=previous[1],
)
if current is None:
# نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
return 0
if previous_type == action.actionType:
return 0 # ویرایش عادی همان نوع ⇒ پیامک تازه‌ای لازم نیست
return current[0](customer, action, created_by=user)
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر."""
if value is None:
return '—'
return JalaliDate(timezone.localtime(value)).strftime('%Y/%m/%d')
def _report_hold_result(request, result):
"""بازخورد شفافِ نتیجهٔ نوشتن «تا تاریخ پیگیری نشود» به کاربر.
قاعدهٔ «فقط رو به جلو» بی‌صدا نیست: اگر تاریخ درخواستی از قرار فعلی عقب‌تر
باشد، اینجا گفته می‌شود که ذخیره نشد و کاربر چطور می‌تواند خودش انجامش دهد.
"""
if result.get('reason') != 'forward_only':
return
current, target = result.get('current'), result.get('target')
tz = timezone.get_current_timezone()
if current is not None and target is not None \
and target.astimezone(tz).date() >= current.astimezone(tz).date():
# همان روز ⇒ تغییر واقعی‌ای لازم نیست؛ پیام دادن گمراه‌کننده است.
return
tail = ('برای ثبت، گزینهٔ «تاریخ قرار را عقب ببر» را تیک بزنید.'
if result.get('force_allowed')
else 'برای تغییر قرار، همان وظیفهٔ هماهنگی را ویرایش کنید.')
messages.warning(
request,
'تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (%s) عقب‌تر است. %s'
% (_jalali_day(current), tail)
)
def _reject(request, error, redirect_to):
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
messages.error(request, '؛ '.join(error.messages))
return redirect(redirect_to)
@login_required(login_url='/panel/p_login/')
def customerNewAction(request ,c_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
# اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته).
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action()
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if payload['is_done']:
newAction.status = Action.Status.FINISH
# تاریخ اتمام خالی نباید None ذخیره شود (سابقه را پاک می‌کرد).
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = Action.Status.WAITE
newAction.save()
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# «قرار بعدی» فقط برای هماهنگی.
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=True,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
_sync_action_event_sms(customer_obj, newAction, user=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@login_required(login_url='/panel/p_login/')
def ActionList(request):
actions = Action.objects.filter(forUser__id=request.user.id, status='waite').order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@login_required(login_url='/panel/p_login/')
def ActionListUser(request, u_id):
actions = Action.objects.filter(forUser__id=u_id, status='waite').order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@login_required(login_url='/panel/p_login/')
def customerEditAction(request ,c_id, action_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(
request, error,
request.META.get('HTTP_REFERER', '/panel/customer/' + str(c_id) + '/action'),
)
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
was_done = newAction.status == Action.Status.FINISH
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
previous_type = newAction.actionType
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if payload['is_done']:
newAction.status = Action.Status.FINISH
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = Action.Status.WAITE
newAction.save()
# بازمحاسبهٔ واحد: هم پس از ویرایش، هم وقتی رکورد از «انجام‌شده» برگشت می‌خورد
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
# باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=False,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
# فقط *گذار* نوع اقدام بررسی می‌شود، نه هر ویرایش (جزئیات در
# ``_sync_action_event_sms``):
# * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده می‌شود.
# * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو می‌شود.
_sync_action_event_sms(
customer_obj, newAction, previous_type=previous_type, user=request.user,
)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@login_required(login_url='/panel/p_login/')
def customerActionDelete(request, c_id, action_id):
"""«حذف» یک اقدام (soft-delete).
فقط POST مجاز است (تغییر وضعیت داده). اقدام فیزیکی حذف **نمی‌شود**؛
``status`` به ``cancell`` می‌رود و ``deleted_at``/``deleted_by`` پر می‌شوند.
چرا soft-delete؟ چون سابقهٔ CRM باید قابل حسابرسی بماند و منطق محاسبات
(entezar / ActionList / داشبورد) از قبل فقط ``waite``/``finish`` را می‌بیند؛
پس همین مقدار، اقدام را از همهٔ خروجی‌ها کنار می‌گذارد.
"""
customer_obj = get_object_or_404(Customer, id=c_id)
action = get_object_or_404(Action, id=action_id, customer=customer_obj)
back_url = '/panel/customer/%d/action' % customer_obj.pk
if request.method != 'POST':
# دسترسی مستقیم با GET نباید چیزی را عوض کند.
return redirect(back_url)
try:
cancelled = soft_delete_action(action, user=request.user)
except ActionDeleteError as error:
messages.error(request, str(error))
return redirect(back_url)
label = action.get_actionType_display() or 'اقدام'
message = '«%s» حذف شد و از فهرست‌های انتظار/وظایف خارج شد.' % label
if cancelled:
message += ' %d پیامک ارسال‌نشدهٔ مرتبط هم لغو شد.' % cancelled
messages.success(request, message)
return redirect(back_url)
@login_required(login_url='/panel/p_login/')
def ActionListAll(request):
# اقدام‌های حذف‌شده (soft-delete) در فهرست «همه» نمایش داده نمی‌شوند.
actions = Action.objects.filter(deleted_at__isnull=True).order_by("-id")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
def _local_day_range(day=None):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی و aware.
قبلاً این دو ویو رشته‌های naive می‌ساختند (``'%Y-%m-%d 00:00:00'``) و Django
آن‌ها را با منطقهٔ زمانی پیش‌فرض تفسیر می‌کرد؛ هر تغییر ساعت/تایم‌زون باعث
جابه‌جایی مرز «وظایف امروز» می‌شد. حالا مرز صریح و aware است.
در روزهای تغییر ساعت (مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته
باشد؛ آن حالت با ``is_dst=False`` پوشش داده می‌شود تا یک تاریخ مرزی، صفحهٔ
وظایف را نشکند (همان کاری که ``visit_policy.as_aware`` می‌کند).
"""
day = day or timezone.localdate()
bounds = []
for moment in (datetime.datetime.combine(day, datetime.time.min),
datetime.datetime.combine(day, datetime.time.max)):
try:
bounds.append(timezone.make_aware(moment))
except Exception:
bounds.append(timezone.make_aware(moment, is_dst=False))
return bounds[0], bounds[1]
@login_required(login_url='/panel/p_login/')
def ActionListToday(request):
startTodayDT, endTodayDT = _local_day_range()
actions = Action.objects.filter(
actionDateTime__range=(startTodayDT, endTodayDT),
forUser__id=request.user.id, status='waite',
).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@login_required(login_url='/panel/p_login/')
def ActionListUserToday(request, u_id):
dateTimeAction = request.GET.get('dateTime', None)
status = request.GET.get('status', None)
unixADT = dateTimeAction
if dateTimeAction:
# timestamp میلی‌ثانیه‌ای GET → تاریخ محلی (نه naive UTC).
moment = as_aware(datetime.datetime.fromtimestamp(int(dateTimeAction) / 1e3))
day = moment.astimezone(timezone.get_current_timezone()).date()
else:
day = timezone.localdate()
startTodayDT, endTodayDT = _local_day_range(day)
if status == 'waite' or status == None or status == '' or status == ' ':
status = 'waite'
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200]
elif status == 'finish':
actionPr = None
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr}
return render(request, 'admin_panel/ActionList.html', data)
@login_required(login_url='/panel/p_login/')
def rezayat(request):
statusRezayat = request.GET.get('status', 'waite')
now = timezone.now()
# محاسبه بازه‌های زمانی ۳۰ روز و ۶۰ روز قبل
dt_30_days_ago = now - datetime.timedelta(days=30)
dt_60_days_ago = now - datetime.timedelta(days=60)
if statusRezayat == 'finish':
# نمایش نظرسنجی‌های تعیین‌تکلیف شده (ثبت شده یا کنسل شده)
actions = Action.objects.exclude(actionType='vosol').filter(
status='finish',
nazarDT__isnull=False
).order_by("-nazarDT")[:500]
else:
statusRezayat = 'waite'
# نمایش نظرسنجی‌های در انتظار با اعمال شرط عدم انقضای زمان
actions = Action.objects.exclude(actionType='vosol').filter(
status='finish',
nazarDT__isnull=True, # نظرسنجی هنوز نهایی نشده
createdDT__gte=dt_60_days_ago # حداکثر ۶۰ روز از ایجاد گذشته باشد
).filter(
Q(finishDateTime__isnull=True) | Q(finishDateTime__gte=dt_30_days_ago) # حداکثر ۳۰ روز از اتمام گذشته باشد
).filter(
Q(nextNazarDT__isnull=True) | Q(nextNazarDT__lte=now) # موعد پیگیری رسیده یا تعویقی نداشته باشد
).order_by("-id")[:500]
data = {'actions': actions, "status": statusRezayat}
return render(request, 'admin_panel/customerRezayat.html', data)
@login_required(login_url='/panel/p_login/')
def rezayatEdit(request, ac_id):
csGrade = request.POST.get('csGrade', None)
csDesc = request.POST.get('csDesc', None)
if csDesc and csGrade:
action = get_object_or_404(Action, id=ac_id)
if csGrade == '-1':
# عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمی‌شود تا نهایی نشود)
action.nazar = csDesc
action.grade = int(csGrade)
action.nextNazarDT = timezone.now() + datetime.timedelta(days=3)
elif csGrade == '-2':
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = timezone.now()
action.nextNazarDT = None
else:
# ثبت امتیاز عادی
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = timezone.now()
action.nextNazarDT = None
action.save()
return redirect('/panel/rezayat/')
else:
return HttpResponse('لطفا همه فیلد ها را پر کنید')
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more