Compare commits

...
4 Commits
Author SHA1 Message Date
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
123 changed files with 138613 additions and 3596 deletions

No files matched your search

+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
+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).
+607
View File
@@ -0,0 +1,607 @@
Project Root: khatibi
Generated At: 2026-10-10T06:32:47+00:00
Python Files: 153
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 54
Total Files: 523
Total Directories: 74
khatibi
├── .cursor
│ └── rules
│ └── graphify.mdc
├── .gemini
│ └── settings.json
├── .github
│ └── copilot-instructions.md
├── .opencode
│ ├── plugins
│ │ └── graphify.js
│ └── opencode.json
├── .run
├── admin
│ ├── migrations
│ │ └── __init__.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── admin_panel
│ ├── management
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── gt_esh.py
│ │ │ ├── send_daily_report.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
│ │ └── __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
│ │ ├── enqueue.py
│ │ ├── phones.py
│ │ ├── recipients.py
│ │ ├── render.py
│ │ ├── rules.py
│ │ ├── scanner.py
│ │ └── timing.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_sms.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
│ │ ├── 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_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
│ ├── services.py
│ ├── signals.py
│ ├── tests.py
│ ├── urls.py
│ ├── views - Copy.py
│ └── viewsBK.py
├── data9
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
│ ├── db222.sqlite3
│ ├── kt.sqlite3
│ ├── kt.sqlite3-shm
│ └── kt.sqlite3-wal
├── graphify-out
│ ├── 2026-10-09
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.82-s7
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
│ │ │ ├── 0442dd7e8445ba796e02aaefde16760c8ed37f63359d4319498be512f60266bd.json
│ │ │ ├── 056f0cb55bfd5a1ddf744c7c824c342048c1db47f3bb350030a1533ab56bf8d7.json
│ │ │ ├── 0b8f8e3b6e4d84256e784dabb96f5646fa6e7295adbd20648c235e7821b4ca1d.json
│ │ │ ├── 0e6bfa570caca1f79f549f5cdc37f8bdf9c9b43a023ca28a8fcb83351389b399.json
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
│ │ │ ├── 1dd0c6958a307a1e4e59c24f20225292277585dd8652b35537ede94fc04807ee.json
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
│ │ │ ├── 2f36d1565911fdcb414da716162085b0fdc4b92d9d2d3fbb9fecc403b1916766.json
│ │ │ ├── 338e7d5d2e7d3a7260ab74d9b7b19bbab6b9ce84e7036623060190d9e0cc4bcd.json
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
│ │ │ ├── 41952e7c846da15fe658603ff001dafdb0e12d3372a7f0306adfa3a71de8bee7.json
│ │ │ ├── 458c1b9a3ce257e789f75305939ab2e79029c557657102eebf2082e50a47a0e5.json
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
│ │ │ ├── 5730d72cc177474584197921cce656696e3177780028c4629950f5514b530c6d.json
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
│ │ │ ├── 58bfae9cf8467d2bb9363884d517e98cae38a934f4c474e9e02aed520fdaab95.json
│ │ │ ├── 58f2e3419fb2de4a3bdc4a9c0bf48e50f2cc225506096109dbf2b9bfe4778bb5.json
│ │ │ ├── 5901400fc36893a9ad85acd97eb73b691cb4b8d1440047c5f85c1534a2c7e492.json
│ │ │ ├── 5b63b5d7fc7a09b2dbf5e2f7db6fe00ccab773762b3291bd666c8e29d32100d3.json
│ │ │ ├── 5ce9ee869c507986bf00ae6929f3aae3ab7aa58b5c2bded4965374fcf7f5654b.json
│ │ │ ├── 5d5b6249e3cfa0aeee94ba2053fc73b4b7b5b8fa1d21df09d40bda7c90ef273d.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
│ │ │ ├── 64fc04d8c553b2121a71955d48257160a40f6dd34258e3287945873f02d897dc.json
│ │ │ ├── 65d5139792e0ee840c5b1710d7659ecfb5dbdcadbd02d205f7517a6a3a374211.json
│ │ │ ├── 6876f75a9cb4a5f5857c47e3b0d6b38e51941aff76bc6d92cb1b4eef1c51071e.json
│ │ │ ├── 6a88f82e5b9f9da12b50f8531eac30c10e1c2a0cf39601d275fd90ee3e28da55.json
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
│ │ │ ├── 71a07328d56403d55714d76cefc05db3e68c6498719e629e4e76889a1940efc2.json
│ │ │ ├── 73a129efd08cc38274f3c56503c234eef17f58faf74dadbdbbe475efcbfc2777.json
│ │ │ ├── 7483cdf00a2d7de8809278d9dcdf2f2330354b02117952da4b9452d4bbd6b81e.json
│ │ │ ├── 74efb913c62b374d818cfc5af6d7ee9bb46966eb2bce0e26e0bba461683b98a7.json
│ │ │ ├── 75d0862c4a7be01c37a92f0baf858c27367f6b242254080cccc5888a758dd0ff.json
│ │ │ ├── 760b5e6a8ce6ddc3b34aae59a11c57c36471f9eb93f8d2e24c7a030c8ad02dbc.json
│ │ │ ├── 761cb648c1272d981720580035fca3a501c423ab819648863f223b83292b4e58.json
│ │ │ ├── 767dd58146a5d4a713611561a9e5c1b706e5f509a5621891c9b2e195f3b8ee0a.json
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
│ │ │ ├── 7e9bad82d0eb642b739dcf11e86c2fcf2005dfb12c94478af143deb9a8129bfa.json
│ │ │ ├── 8055bf9fe36a9fa183d0c5dc152684e65410d58c5634e544e8f38f6c9216f6ad.json
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
│ │ │ ├── 86e57806cd08cf765ce1f56f0638dd321e93c653bb6613aa35d7ad375808433c.json
│ │ │ ├── 88e349e879d601e1f5c75f3b019ffb4958e90639317ba893c54f82b0cc9066c2.json
│ │ │ ├── 8ffb83ce6f13d2f97d0197c7381748e9b66956c13f0158bc519350b7dd1b9ad0.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 940d0349821b0fb7f2c3effcb81fd13bb1c046daa6f6c5f2cd1b157058c55877.json
│ │ │ ├── 957f37be1d5d6a5207f90ad07704f8423c915c5bcdfcd130acc85a388fc89d19.json
│ │ │ ├── 96c3ba545db191ae5811d5dfdde74e7c6396a932b759513c94160cb7b7c81fc8.json
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
│ │ │ ├── a823c9c28027c8d525714e6571bc3f3ca38f3bd0420aaf4f4921067cf0672475.json
│ │ │ ├── a8311f6e51027ea4606e35bf14d50edc1795e71b2d63cddeae8c7bb7ece215b8.json
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
│ │ │ ├── b26c22033963df1727407c1fa6c79d8241bfc16056e7a8d3c4383fb2d050d3cd.json
│ │ │ ├── b26e251bde84a040dbb2d1a4f700ebcd761f51a124a98a104704e82537e320eb.json
│ │ │ ├── b34cb0a1a28e753a900b3d23f01d3810f9c06635b0650d34be4ec9d98daa8fbb.json
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
│ │ │ ├── bcea7c584e5031a9c5734144f8169d016ef1ed316e6f4827609509ba2a79d3eb.json
│ │ │ ├── bd6e156d8e427b14676b4707710f2b340026e679cb7bd29b22b247a4b637e7bd.json
│ │ │ ├── be1200d7c9b2881ba54c7fa95daf5f3e2ce993619b974c7760a9d44a416e00c0.json
│ │ │ ├── c46ad3fc8fd3db1315ab2e40add760b44e8f98c24c79e334c342c6a411fc671c.json
│ │ │ ├── c52300dce4cf464158f8db56df5030359a501a81bbe4a5f36e50c8c38a5ac48c.json
│ │ │ ├── c5c93ccc7715c31fc7538bcd9327c8fb7c1e495bd17d6444b384ec426b7a231f.json
│ │ │ ├── c72226544540cc75217ba02f1d09d989eb810d879409ac086c76245f27220cfa.json
│ │ │ ├── c795f43bd1f10cf666ff987d18af538b2164c1683629ac689f0a54cb875118be.json
│ │ │ ├── c7eb97fa16701c6d9ee531fa032479a7c771292f3e9d8921d3ea12a4897fd43a.json
│ │ │ ├── c846b5de286163b5eed16ff6d6e00f2038f30bf7edd2ab3f8d831369e190ae18.json
│ │ │ ├── cdc5059cf09416d24d03fe57a48af5219727866c84b8109d986456fbaa789dc4.json
│ │ │ ├── cf6668c54cd4f65d268a17df44f100e1cb77f91eae90dd2dc052564731b2f195.json
│ │ │ ├── d1289d5406d360e3ff617e213db4e262cd43a32711a61aba97bdcd0a906cbcb5.json
│ │ │ ├── d3456cd690135f9630e6b0bb79fef5d205804c7acabffc61cbd5ac4032b17bc7.json
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
│ │ │ ├── e3ee8d9942c74a74be83ca00d5438e4a1e1cb5b23f55d95d308a42954e4757f0.json
│ │ │ ├── e9589ded446a5befb285a0a2e93a182ca9431c76661e68c9210ed252580dd7f0.json
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
│ │ │ ├── ee557c6ed214858651cd3ebceb5e0fd00999354a4c54bb379f26c64b48b509ab.json
│ │ │ ├── efd113025c3fb3c0becaecd8090e8494b13d92ff3c49220d54665b3a8af586af.json
│ │ │ ├── f1af209a02f2ec5c5a344e3fa7dd8ad7d443e0479e55f5465449c0a2eed314a1.json
│ │ │ ├── f3a6285dcf9eb39df500bc0b76b03b5941103e8b90c06ffaeab1b3129f18fedc.json
│ │ │ ├── f537b36e2ee33d1303c10e950a6910cc7fd954c7fce3220be2c95d75d70dfd40.json
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
│ │ │ ├── fd714e03c631694d30457ac529d05d8d131c183665e086246da607f752dea5eb.json
│ │ │ └── ff862e0ffad7bba911716072ae82f268240d82105ffefe78c7714a160c4c31ba.json
│ │ ├── semantic
│ │ │ └── p5e80268fecd6
│ │ │ ├── 0c4f87b180cd5b0576b197ff5cb0e9cc5dfd8d82d97d618036df00c7955fdbac.json
│ │ │ ├── 14e03f8f9b4ebd7cf018188e2351ca859aa4e27d02d5dbfc7b1f8b932d282d16.json
│ │ │ ├── 2679b1fb1647af24f007334e6560733c957ca3cf3831eb02a9c8fb7a7ae44d62.json
│ │ │ ├── 296d532aced4658e74a296977efe4fd8a2ec3cf02a997ce3837f59ce36e61343.json
│ │ │ ├── 2a13d2bb6f18a81d9511481df0edcdb1a8779e1ec03e919991693cae7e84c8e0.json
│ │ │ ├── 2d3b8c0bb7eea6a50a24093abfc3457c0938079b0771b2568498060f6f3a4a74.json
│ │ │ ├── 40373bd2bcd76ed8ed27031299e541005d4c8c0c7df312a9312f390b173d7248.json
│ │ │ ├── 42a95a4a8f651dbebe297ce92beca376cb19eab99d6ef6b5d10a16d79d1842e4.json
│ │ │ ├── 4f53e6b5fc48381f8afe6052371989c4df69347c84c2ec65a46b73cd1c464b5f.json
│ │ │ ├── 538cdc2fb71798c581f20d37d52a1484c2ef21691d2e01ee79edcf253ba06394.json
│ │ │ ├── 569db52f8b9229dc3cd5cbde156a4ac012ab711d32bdeb4175b60ba863ac8cd0.json
│ │ │ ├── 57dba5c2a37beb12ccc107807b7544f687d6d8ba6394c8977c3c59b433d2dab4.json
│ │ │ ├── 5ad5f87fc2eb132405e3aab9ef8a84e41f76c958fbcb188873c86abd4fbc12fd.json
│ │ │ ├── 6aaa93a769a41eb1d37a91608cd6f98b9252c2a5598bd859a130986429d1d53f.json
│ │ │ ├── 7b504b4173cb8d683a7021270372c5c65f078ac3e3e1351601bedfec9c585632.json
│ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json
│ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json
│ │ │ ├── 8f60bda631cbe7457886d1d574b2e3d2850034f469592a3e8c333f0623a2d3c6.json
│ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json
│ │ │ ├── 9b674bec71dfe6e133af9fb4e35a421fa56cecd230544008ed83a44e6eaae3bf.json
│ │ │ ├── aa41b77ce8563f4b4bee015be5ffa598f1c98d4f2854ec9a465f271e8c3f974b.json
│ │ │ ├── b5f6ef338a857f51d581abe7c50f57f41757aa6900bf94f6ed8fc2ad442f6019.json
│ │ │ ├── be1200d7c9b2881ba54c7fa95daf5f3e2ce993619b974c7760a9d44a416e00c0.json
│ │ │ ├── c1d9e0aa5d7450242681eebd003d4f502744a3624112b10ab091011a50820ce3.json
│ │ │ ├── c97d49fed39b9aca8dea35a49c3e031a641420e4452e61d1ea2a912e899f6dd9.json
│ │ │ ├── cacf6773249daafca21d21004106209d86aa215140de071c64363aff4d9e5431.json
│ │ │ ├── cfb57017bc764ee7e9011b52f1e44ad2f41b2670778af73232ecfc309b406a0d.json
│ │ │ ├── dc89fcddf8c6f20bc9f52ae15e5cc8b226320d2776dc8f27be16894e53165013.json
│ │ │ ├── dd78862d0f7862653c702e86f7acfabfe33ec5d85d40ba764138c0d1784f7b06.json
│ │ │ ├── de67f63e3f49d4ee32811dd67676da33a1e584f671d396fca130f3681d54bd76.json
│ │ │ ├── e0a52ba8204140f195e8d30847dda00d3de5439cf903c576b77fac1ec852b5fb.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
│ │ │ ├── 40373bd2bcd76ed8ed27031299e541005d4c8c0c7df312a9312f390b173d7248.json
│ │ │ ├── 40a84c5465882009ab69d2874adbb9aaed373cd706d70c92e9e1f4c35640472e.json
│ │ │ ├── 4280dd1094314da773fe73ba1ffb91f58f22eea26281bd5cb51f5e9a201c95f6.json
│ │ │ ├── 42a95a4a8f651dbebe297ce92beca376cb19eab99d6ef6b5d10a16d79d1842e4.json
│ │ │ ├── 4b8f3b6c0ccb3b5494fb2af8c0f9892b6b198a8d503f53498bd1fe2548ef112a.json
│ │ │ ├── 4f53e6b5fc48381f8afe6052371989c4df69347c84c2ec65a46b73cd1c464b5f.json
│ │ │ ├── 538cdc2fb71798c581f20d37d52a1484c2ef21691d2e01ee79edcf253ba06394.json
│ │ │ ├── 569db52f8b9229dc3cd5cbde156a4ac012ab711d32bdeb4175b60ba863ac8cd0.json
│ │ │ ├── 57dba5c2a37beb12ccc107807b7544f687d6d8ba6394c8977c3c59b433d2dab4.json
│ │ │ ├── 5ad5f87fc2eb132405e3aab9ef8a84e41f76c958fbcb188873c86abd4fbc12fd.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 6aaa93a769a41eb1d37a91608cd6f98b9252c2a5598bd859a130986429d1d53f.json
│ │ │ ├── 7101777f271bbc6bce9a2a4fd3bcf487bb62a846a2436bc8c70008bfb23ce33b.json
│ │ │ ├── 7b504b4173cb8d683a7021270372c5c65f078ac3e3e1351601bedfec9c585632.json
│ │ │ ├── 7db83b9e384d80a1584b152f9fbc781e0ae5a7524dac0a57ce98bb4d3432bfae.json
│ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json
│ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json
│ │ │ ├── 8f60bda631cbe7457886d1d574b2e3d2850034f469592a3e8c333f0623a2d3c6.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 92e1d82ed4571249c09163413ceae360dd4d7d03c162c554f3b5a47e94474cf1.json
│ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json
│ │ │ ├── 9b674bec71dfe6e133af9fb4e35a421fa56cecd230544008ed83a44e6eaae3bf.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
│ │ │ ├── e0a52ba8204140f195e8d30847dda00d3de5439cf903c576b77fac1ec852b5fb.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
│ ├── __init__.py
│ ├── announcements.py
│ └── voip.py
├── khatibi
│ ├── __init__.py
│ ├── asgi.py
│ ├── production_settings.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── static
│ └── .gitkeep
├── tests
│ ├── __init__.py
│ ├── factories.py
│ ├── test_customers.py
│ ├── test_factors.py
│ ├── test_inventory.py
│ ├── test_models_contract.py
│ ├── test_sms.py
│ ├── test_urls_contract.py
│ └── test_view_smoke.py
├── .dockerignore
├── .gitignore
├── .graphifyignore
├── AGENTS.md
├── check_finance.py
├── db.json
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── financial_audit_20260816_151225.csv
├── GEMINI.md
├── ghost_hunter.py
├── khatibi.zip
├── local.env
├── manage.py
├── production.env
├── project_tree.py
├── 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,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,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='ارسال خودکار (بدون تأیید مدیر)'),
),
]
-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
+38
View File
@@ -0,0 +1,38 @@
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):
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)
+60
View File
@@ -0,0 +1,60 @@
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…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
)
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
)
+314
View File
@@ -0,0 +1,314 @@
"""مدل‌های زیرسیستم پیامک.
شامل:
- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin.
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری.
- صف ارسال (outbox) و لاگ تلاش‌های ارسال.
"""
from django.conf import settings
from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم."""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)"
VISIT = "visit", "یادآوری بازدید خدمات"
SURVEY = "survey", "نظرسنجی پس از فاکتور"
CUSTOM = "custom", "پیام سفارشی"
class SMSProviderConfig(models.Model):
"""تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin."""
class Meta:
verbose_name = "تنظیمات سامانه پیامک"
verbose_name_plural = "تنظیمات سامانه پیامک"
permissions = [
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
]
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} شماره ردیف چک، {owner_name} نام مالک چک.",
)
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("تعداد انتقال باید بیشتر از صفر باشد.")
+9
View File
@@ -0,0 +1,9 @@
from django.db import models
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})"
+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 @@
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
+74
View File
@@ -0,0 +1,74 @@
"""قرار دادن پیامک در صف ارسال (insert-only، بی‌خطر برای مسیر request)."""
import logging
from uuid import uuid4
from django.db import IntegrityError
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
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`` که مدیر در صفحهٔ قالب‌ها برای هر نوع
پیامک روشن می‌کند تا پیامک‌های خودکارِ اسکنر بدون صف تأیید ارسال شوند.
هیچ استثنایی به فراخوان پرتاب نمی‌شود؛ خطاها فقط لاگ می‌شوند تا مسیر اصلی
(مثلاً ایجاد مشتری یا اسکنر) هرگز به‌خاطر پیامک مختل نشود.
"""
try:
if message is None:
template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
if template is None:
return 0
message = render_template(template.text, context)
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالب‌ها تعیین می‌شود؛ خاموش = بازبینی مدیر.
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
+59
View File
@@ -0,0 +1,59 @@
"""پیام‌های نتیجهٔ عملیات پنل پیامک.
جایگزین 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", "پیامک از صف حذف شد."),
"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"),
}
+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)
+41
View File
@@ -0,0 +1,41 @@
"""رندر قالب متن پیامک با placeholderهای امن."""
from persiantools.jdatetime import JalaliDate, JalaliDateTime
class _SafeDict(dict):
"""دیکشنری که برای کلیدهای ناشناخته رشته خالی برمی‌گرداند (بدون خطا)."""
def __missing__(self, key):
return ""
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 "date" in ctx:
ctx.setdefault("date_raw", ctx["date"])
ctx["date"] = format_jalali(ctx["date"])
return (text or "").format_map(_SafeDict(ctx))
+25
View File
@@ -0,0 +1,25 @@
"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out)."""
from ..models import CustomerSMSSetting, SMSType
def is_sms_enabled(customer, sms_type):
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال."""
if customer is None or sms_type == SMSType.CUSTOM:
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
if code != SMSType.CUSTOM
]
+336
View File
@@ -0,0 +1,336 @@
"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
* **خط شروع (``scan_since``)**: فقط مواردی صف می‌شوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
آن‌ها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچ‌کدام از موارد قدیمی موجود
در دیتابیس یک‌باره ارسال نمی‌شوند.
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره
ساخته می‌شود؛ برای همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.db.models import Q, Sum
from django.utils import timezone
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
SMSTemplate, SMSType)
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# پیش‌فرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
CHEQUE_REG_INTERVAL_DEFAULT = 3
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند)."""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
return value
# date → آغاز همان روز
return timezone.make_aware(
datetime.combine(value, time.min), timezone.get_current_timezone(),
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config):
self.since = config.scan_since
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):
trigger = _aware(trigger)
return self.since is not None and trigger < self.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_due(today):
template = _template(SMSType.CHEQUE_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
qs = Sarresid.objects.filter(
type="check", 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
# پنجرهٔ یادآوری از «سررسید منهای offset» باز می‌شود.
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.CHEQUE_DUE,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
},
dedup_key=f"cheque_due:{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_cheque_registration(today):
"""یادآوری «ثبت اطلاعات چک» برای چک‌های پرداخت‌نشدهٔ ثبت‌نشده.
هر ``interval`` روز یک‌بار تکرار می‌شود و به‌محض اینکه شمارهٔ صیادی چک ثبت شود
(یا چک پرداخت شود) متوقف می‌گردد. دورهٔ تکرار از پنل قابل تنظیم است.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__gte=today,
).filter(
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
).select_related("customer")
count = 0
for s in qs:
if not s.customer:
continue
anchor = s.createdDT or s.sarresidDT
if not anchor:
continue
days = (today - anchor.date()).days
if days <= 0 or days % interval != 0:
continue
cycle = days // interval
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
trigger = _aware(anchor) + timedelta(days=days)
if not budget.can_send(trigger):
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": (s.sarresidDT.date() - today).days
if s.sarresidDT else "",
"check_row": s.check_row_number,
},
dedup_key=f"cheque_reg:{s.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_debt_due(today):
"""یادآوری بدهی برای مانده‌دارها روی سررسید وصول مطالبات."""
template = _template(SMSType.DEBT_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
payStatus="false", sarresidDT__date=target,
).select_related("customer").order_by("sarresidDT")
count = 0
seen = set()
for s in qs:
if not s.customer or s.customer_id in seen or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT)
if not budget.can_send(trigger):
continue
totals = Article.objects.filter(customer=s.customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
net = (totals["d"] or 0) - (totals["c"] or 0)
if net <= 0:
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.DEBT_DUE,
context={"name": s.customer.name_family, "amount": net},
dedup_key=f"debt_due:{s.id}",
)
if made:
seen.add(s.customer_id)
budget.record(trigger)
count += made
return count
def scan_survey(today):
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
template = _template(SMSType.SURVEY)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 3
target = today - timedelta(days=offset)
qs = Factor.objects.filter(
is_active=True, create_date__date=target,
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
count = 0
for f in qs:
if not f.customer or not f.create_date:
continue
trigger = _aware(f.create_date) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=f.customer, sms_type=SMSType.SURVEY,
context={"name": f.customer.name_family, "amount": f.amount},
dedup_key=f"survey:{f.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
template = _template(SMSType.VISIT)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیش‌فرض.
months = template.repeat_interval_months
if not months:
try:
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
except (TypeError, ValueError):
months = DEFAULT_VISIT_MONTHS
period = timedelta(weeks=int(months * 4.3))
period_days = max(period.days, 1)
now = timezone.now()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=(now - period).date(),
).order_by("id")
count = 0
for customer in qs:
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
if anchor is None:
continue
trigger = anchor + period
if trigger > now:
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
cycle = max((today - anchor.date()).days // period_days, 0)
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
SCANNERS = [
("cheque_due", scan_cheque_due),
("nesiye_due", scan_nesiye_due),
("cheque_registration", scan_cheque_registration),
("debt_due", scan_debt_due),
("survey", scan_survey),
("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
+112
View File
@@ -0,0 +1,112 @@
"""معنای «زمان‌بندی» برای هر نوع پیامک.
هر نوع پیامک زمان‌بندی متفاوتی دارد و نمی‌شود همه را زیر یک برچسب «روز قبل از سررسید»
جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالب‌ها بر اساسش فیلد درست را نشان
می‌دهد، هم اسکنر بر اساسش تصمیم می‌گیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت می‌شود
(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی می‌مانند.
"""
from ..models import SMSType
NONE = "none"
BEFORE_DUE = "before_due"
AFTER_CREATION = "after_creation"
RECURRING = "recurring"
PERIOD_MONTHS = "period_months"
# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
DEFAULT_VISIT_MONTHS = 9
TIMING = {
SMSType.WELCOME: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید چک",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": RECURRING,
"field": "repeat_interval_days",
"label": "هر چند روز یک‌بار",
"default": 3,
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
"با این فاصله تکرار می‌شود.",
},
SMSType.SURVEY: {
"kind": AFTER_CREATION,
"field": "offset_days",
"label": "چند روز بعد از صدور فاکتور",
"default": 3,
"note": "این تعداد روز پس از ثبت فاکتور ارسال می‌شود.",
},
SMSType.DEBT_DUE: {
"kind": NONE,
"field": None,
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال می‌شود.",
},
SMSType.VISIT: {
"kind": PERIOD_MONTHS,
"field": "repeat_interval_months",
"label": "هر چند ماه یک‌بار",
"default": DEFAULT_VISIT_MONTHS,
"note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.",
},
SMSType.CUSTOM: {
"kind": NONE,
"field": None,
"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
@@ -0,0 +1,70 @@
{% if perms.admin_panel.ManageSMS %}
<div class="box" dir="rtl">
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">شماره‌های اصلی (گیرنده پیامک)</h3>
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #}
{% for entry in sms_phone_entries %}
<form method="post" action="{% url 'admin_panel:sms_primary_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="phone" value="{{ entry.raw }}">
<input type="hidden" name="is_primary" value="{% if entry.is_primary %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text"
value="{{ entry.title }} — {{ entry.display }}{% if not entry.is_mobile %} (غیرقابل ارسال پیامک){% endif %}"
disabled>
</div>
<div class="control">
{% if entry.is_mobile %}
<button class="button {% if entry.is_primary %}is-success{% else %}is-light{% endif %}" type="submit">
{% if entry.is_primary %}اصلی ✓{% else %}تعیین به‌عنوان اصلی{% endif %}
</button>
{% else %}
<button class="button is-light" type="button" disabled>غیرموبایل</button>
{% endif %}
</div>
</form>
{% empty %}
<p class="help">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
<p class="help">اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.</p>
<p class="help">گیرندگان فعلی: {% for r in sms_recipients %}<span dir="ltr">{{ r }}</span>{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}</p>
</div>
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
{% 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 %}">
<div class="control is-expanded">
<input class="input" type="text" value="{{ item.label }}" disabled>
</div>
<div class="control">
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="column is-12">
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک... (خالی بگذارید تا متن قالب «پیام سفارشی» استفاده شود)"></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
<button class="button is-info" type="submit">ارسال به شماره‌های اصلی</button>
</form>
</div>
</div>
</div>
{% 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,23 @@
{% if perms.admin_panel.ManageSMS %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
<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>
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
</div>
</div>
{% endif %}
<a class="navbar-item">
کاربران
</a>
@@ -201,6 +219,17 @@
{% 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 %}
@@ -45,86 +45,29 @@
<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 %}
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (جدول شماره‌ها + فیلدهای قدیمی)، بدون تکرار #}
{% 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.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 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">
@@ -148,6 +91,13 @@
</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">
@@ -686,7 +636,7 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#formcsEdit").find("input, textarea").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
@@ -180,6 +180,8 @@
</form>
</div>
{% include "./_customer_sms.html" %}
</div>
{% endblock %}
+209 -100
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">
{% csrf_token %}
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<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>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
{% csrf_token %}
<div class="add-modal-body">
<div class="columns is-mobile is-multiline m-0">
<div class="field column is-6-desktop is-12-mobile m-0">
<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="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" id="addName" class="input an_name" type="text"
autocomplete="name" placeholder="نام و نام خانوادگی">
</div>
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
<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="idnCode" id="addIdnCode" class="input idnCode" type="text"
placeholder="کد شناسایی مشتری">
</div>
</div>
<div class="control">
<button type="button" class="button is-info add-phone-btn">+</button>
<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="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="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>
<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>
</form>
<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,83 +276,168 @@
</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
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".closeSearch").click(function(){
resetAddForm();
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".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")
});
$("#openSearchBox").click(function(){
$("#addNewUser").click(function(){
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")
});
$("#bedehiCN01").click(function(){
});
$("#bedehiCN01").click(function(){
var factorId = $(this).parent().parent().find(".fid").val();
if ($("#bedehiStatus").val() == "tasfiye"){
alert("send To server to tasfiye");
$("tbody").find('.fidv-'+factorId).parent().parent().remove();
$(".mEdit1").removeClass("is-active");
}
});
});
} );
</script>
{% endblock %}
@@ -0,0 +1,149 @@
{% 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> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
</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>
{% 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 %}
<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>
</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,188 @@
{% 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 %}
<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>
</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>
</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>
</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">
برای 0098SMS همین آدرس لینکی ارسال است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و آدرس WSDL باعث خطای «Invalid XML content» می‌شود.
</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">
ارسال با شناسهٔ پیامک (<span dir="ltr">smsid</span>) و استعلام تحویل به گوشی
از همین آدرس انجام می‌شود. پیش‌فرض:
<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">اگر خالی بماند از «شماره خط فرستنده» استفاده می‌شود.</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>
</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>
</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>
</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>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
برای جلوگیری از سیل پیامک: تنها مواردی ارسال می‌شوند که پنجرهٔ یادآوری‌شان از
«خط شروع» به بعد باز شود. موارد قدیمی‌تر تنها با روشن‌کردن «اجازهٔ موارد قدیمی»
و در سقف تعیین‌شده ارسال می‌شوند. بین هر پیامک هم فاصلهٔ زمانی رعایت می‌شود.
</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">
فقط یادآوری‌هایی صف می‌شوند که «پنجرهٔ» آن‌ها از این تاریخ به بعد باز شده باشد.
مشتریان قدیمی‌تری که پنجره‌شان پیش از این تاریخ باز شده، وارد صف نمی‌شوند.
قالب: <span dir="ltr">YYYY/MM/DD</span> (شمسی) — خالی = همین حالا.
</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>
</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>
</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>
</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">صفر = هیچ مورد قدیمی ارسال نمی‌شود.</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 %}
+11 -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,17 @@ 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()
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
register.filter('toJalaliDT', toJalaliDT)
+14
View File
@@ -80,4 +80,18 @@ urlpatterns = [
path('addSarresid/<int:cs_id>', views.addSarresid, name='addSarresid'),
path('pardakhtNesiye/<int:cs_id>', views.pardakhtNesiye, name='pardakhtNesiye'),
# === مدیریت پیامک ===
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/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
+263
View File
@@ -0,0 +1,263 @@
import datetime
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from ..models import Action, Customer
@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')
# کوئری پایه برای اقدامات این مشتری
actions = Action.objects.filter(customer=customer)
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
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).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)
@login_required(login_url='/panel/p_login/')
def customerNewAction(request ,c_id):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
#return HttpResponse(float(dateTimeAnjam+".0"))
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action()
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
else:
newAction.status = 'waite'
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
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):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
print("ok")
#return HttpResponse(float(dateTimeAnjam+".0"))
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
else:
newAction.status = 'waite'
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
#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 ActionListAll(request):
actions = Action.objects.filter().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)
@login_required(login_url='/panel/p_login/')
def ActionListToday(request):
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=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:
dateTimeAction = datetime.datetime.fromtimestamp(int(dateTimeAction)/1e3)
startTodayDT = dateTimeAction.strftime('%Y-%m-%d 00:00:00')
endTodayDT= dateTimeAction.strftime('%Y-%m-%d 23:59:59')
else:
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
if status == 'waite' or status == None or status == '' or status == ' ':
status = 'waite'
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=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__gte=startTodayDT,actionDateTime__lte=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 = datetime.datetime.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 = datetime.datetime.now() + datetime.timedelta(days=3)
elif csGrade == '-2':
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nextNazarDT = None
else:
# ثبت امتیاز عادی
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nextNazarDT = None
action.save()
return redirect('/panel/rezayat/')
else:
return HttpResponse('لطفا همه فیلد ها را پر کنید')
+29
View File
@@ -0,0 +1,29 @@
from django.shortcuts import redirect, render
from django.contrib.auth import authenticate, login, logout
def p_login(request):
username = request.POST.get('username', None )
password = request.POST.get('password', None )
if username != None and password != None:
if not request.user.is_authenticated:
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect("/panel/ActionList/"+str(request.user.id)+"/today")
else:
data = {'message': 'یوزنیم یا پسورد اشتباه است'}
return render(request, 'admin_panel/login.html', data)
else:
return redirect("/panel/")
else:
if request.user.is_authenticated:
return redirect("/panel/")
else:
return render(request, 'admin_panel/login.html')
#return logout(request)
def p_logout(request):
logout(request)
return redirect("/panel/p_login/")
+273
View File
@@ -0,0 +1,273 @@
import asyncio
import base64
import json
import os
import re
import cv2
import ddddocr
import numpy as np
from asgiref.sync import async_to_sync
from django.contrib.auth.decorators import login_required, permission_required
from django.http import JsonResponse
from playwright.async_api import async_playwright
from ._helpers import logger, _safe_int
# =====================================================================
# توابع کمکی و منطق استعلام چک (برگرفته از اسکریپت‌های شما)
# =====================================================================
def solve_base64_captcha(base64_string: str) -> str:
try:
if ',' in base64_string:
base64_string = base64_string.split(',')[1]
image_bytes = base64.b64decode(base64_string)
np_arr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
if img is None:
return ""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.medianBlur(gray, 3)
_, thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = np.ones((2, 2), np.uint8)
cleaned_img = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=1)
final_img = cv2.bitwise_not(cleaned_img)
success, encoded_image = cv2.imencode('.png', final_img)
if not success:
return ""
processed_bytes = encoded_image.tobytes()
ocr = ddddocr.DdddOcr(show_ad=False)
extracted_text = ocr.classification(processed_bytes)
numbers = re.findall(r'\d+', extracted_text)
if len(numbers) >= 2:
num1, num2 = int(numbers[0]), int(numbers[1])
if '+' in extracted_text:
ans = num1 + num2
elif '-' in extracted_text:
ans = num1 - num2
elif '*' in extracted_text or 'x' in extracted_text.lower():
ans = num1 * num2
else:
ans = num1 + num2
return str(ans)
return ""
except Exception:
return ""
def parse_cheque_result_html(html_content: str) -> dict:
sayad_id = re.search(r'شناسه\s*<b>?\s*(\d+)\s*</b>?', html_content)
owner_name = re.search(r'متعلق به\s*<b>?\s*(.*?)\s*</b>?\s*می باشد', html_content)
validity_date = re.search(r'تا تاریخ\s*<b>?\s*([\d/]+)\s*</b>?', html_content)
status = re.search(r'در وضعیت\s*<b>?\s*(.*?)\s*</b>?\s*در پایگاه', html_content)
clean_full_text = re.sub(r'<[^>]+>', '', html_content).strip()
return {
"success": True,
"data": {
"sayad_id": sayad_id.group(1) if sayad_id else "",
"owner_name": owner_name.group(1).strip() if owner_name else "",
"validity_date": validity_date.group(1) if validity_date else "",
"status": status.group(1).strip() if status else "",
"raw_message": clean_full_text
},
"error": None
}
async def run_cbi_sayad_inquiry_logic(sayad_id: str, max_retries: int = 7) -> str:
lightpanda_url = os.environ.get("LIGHTPANDA_URL") or "ws://127.0.0.1:9222"
async with async_playwright() as p:
try:
browser = await p.chromium.connect_over_cdp(lightpanda_url)
except Exception as e:
return json.dumps({"success": False, "data": None, "error": f"عدم اتصال به Lightpanda: {str(e)}"})
for attempt in range(1, max_retries + 1):
context = await browser.new_context(ignore_https_errors=True, bypass_csp=True)
page = await context.new_page()
try:
url = "https://cbi.ir/EstelamSayad/19689.aspx"
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
captcha_selector = "#ctl00_ucBody_ucContent_ctl00_imgcpatcha"
await page.wait_for_selector(captcha_selector, state="visible", timeout=15000)
await asyncio.sleep(1)
base64_src = await page.locator(captcha_selector).get_attribute("src")
viewstate = await page.locator("#__VIEWSTATE").get_attribute("value") or ""
viewstate_gen = await page.locator("#__VIEWSTATEGENERATOR").get_attribute("value") or "453B48D8"
if not base64_src or not viewstate:
await context.close()
continue
captcha_answer = solve_base64_captcha(base64_src)
if not captcha_answer:
await context.close()
continue
ajax_raw_response = await page.evaluate("""
async ({ sayadId, captchaAns, viewState, viewStateGen }) => {
try {
const formData = new URLSearchParams();
formData.append('ctl00$ucBody$ucContent$ctl00$ScriptManager1', 'ctl00$ucBody$ucContent$ctl00$UpdatePanel1|ctl00$ucBody$ucContent$ctl00$btnSearch');
formData.append('__EVENTTARGET', '');
formData.append('__EVENTARGUMENT', '');
formData.append('__VIEWSTATE', viewState);
formData.append('__VIEWSTATEGENERATOR', viewStateGen);
formData.append('__VIEWSTATEENCRYPTED', '');
formData.append('ctl00$ucHeader$txtQuickSearch', '');
formData.append('ctl00$ucBody$ucContent$ctl00$txtChequeNumber', sayadId);
formData.append('ctl00$ucBody$ucContent$ctl00$txtCaptchaInput', captchaAns);
formData.append('__ASYNCPOST', 'true');
formData.append('ctl00$ucBody$ucContent$ctl00$btnSearch', 'استعلام چک صیادی');
const response = await fetch(window.location.href, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Cache-Control': 'no-cache'
},
body: formData.toString()
});
return await response.text();
} catch (err) {
return 'FETCH_ERROR: ' + err.message;
}
}
""", {'sayadId': sayad_id, 'captchaAns': captcha_answer, 'viewState': viewstate, 'viewStateGen': viewstate_gen})
if "FETCH_ERROR:" in ajax_raw_response:
await context.close()
await asyncio.sleep(1)
continue
error_match = re.search(r'id="ctl00_ucBody_ucContent_ctl00_lblError"[^>]*>(.*?)</span>', ajax_raw_response, re.DOTALL)
if error_match and error_match.group(1).strip():
clean_error = re.sub(r'<[^>]+>', '', error_match.group(1)).strip()
if "عبارت محاسباتی" in clean_error:
await context.close()
continue
else:
await context.close()
await browser.close()
return json.dumps({"success": False, "data": None, "error": clean_error})
result_match = re.search(r'id="ctl00_ucBody_ucContent_ctl00_lblChequeNumberResult"[^>]*>(.*?)</span>', ajax_raw_response, re.DOTALL)
if result_match:
raw_html = result_match.group(1)
parsed_result = parse_cheque_result_html(raw_html)
await context.close()
await browser.close()
return json.dumps(parsed_result)
await context.close()
except Exception:
await context.close()
await asyncio.sleep(1)
await browser.close()
return json.dumps({"success": False, "data": None, "error": "استعلام ناموفق بود (خطای شبکه یا تشخیص کپچا)"})
async def run_bpi_cheque_inquiry_logic(national_id: str, sayad_id: str, id_type: int = 1):
lightpanda_url = os.environ.get("LIGHTPANDA_URL") or "ws://127.0.0.1:9222"
async with async_playwright() as p:
try:
browser = await p.chromium.connect_over_cdp(lightpanda_url)
except Exception as e:
return {"success": False, "error": f"عدم اتصال به مرورگر Lightpanda: {str(e)}"}
context = await browser.new_context()
page = await context.new_page()
try:
await page.goto("https://vbank.bpi.ir/public/inquiries/inquiry-bounced-cheques", wait_until="domcontentloaded", timeout=20000)
api_url = f"https://sec.bpi.ir/prls/api/v1/inquiry/chequeStatus?IdCode={national_id}&IdType={id_type}&SayadId={sayad_id}"
response = await context.request.get(
api_url,
headers={
"Referer": "https://vbank.bpi.ir/public/inquiries/inquiry-bounced-cheques",
"Accept": "application/json, text/plain, */*"
}
)
if response.ok:
data = await response.json()
return {"success": True, "data": data}
else:
error_text = await response.text()
return {"success": False, "error": f"سرور خطا برگرداند (کد {response.status}): {error_text}"}
except Exception as e:
return {"success": False, "error": f"خطایی در فرآیند استعلام رخ داد: {str(e)}"}
finally:
await context.close()
await browser.close()
# =====================================================================
# View های مربوط به استعلام (API)
# =====================================================================
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def api_check_status_inquiry(request):
"""
استعلام وضعیت چک از بانک مرکزی
پارامتر ورودی: sayad_id (GET)
"""
sayad_id = request.GET.get('sayad_id', '').strip()
if not sayad_id or not sayad_id.isdigit() or len(sayad_id) != 16:
return JsonResponse({"success": False, "error": "شناسه صیادی باید دقیقاً ۱۶ رقم باشد."})
try:
# اجرای تابع Async به صورت Sync
result_json_str = async_to_sync(run_cbi_sayad_inquiry_logic)(sayad_id)
# تبدیل رشته جیسون به دیکشنری برای ارسال مجدد با JsonResponse
result_data = json.loads(result_json_str)
return JsonResponse(result_data)
except Exception as e:
logger.error(f"Error in CBI Check Status Inquiry: {str(e)}", exc_info=True)
return JsonResponse({"success": False, "error": "خطای سرور در برقراری ارتباط با بانک مرکزی."})
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def api_check_sabt_inquiry(request):
"""
استعلام ثبت چک از بانک پاسارگاد
پارامترهای ورودی: sayad_id, national_id, id_type (GET)
"""
sayad_id = request.GET.get('sayad_id', '').strip()
national_id = request.GET.get('national_id', '').strip()
id_type = _safe_int(request.GET.get('id_type', 1)) # عدد 1 حقیقی، 2 حقوقی
# اگر کدملی از فرانت‌اند خالی ارسال شد، از یک کدملی پیش‌فرض استفاده می‌کنیم
if not national_id:
national_id = "1219968218"
if not sayad_id:
return JsonResponse({"success": False, "error": "شناسه صیادی برای استعلام ثبت الزامی است."})
try:
# اجرای تابع Async به صورت Sync
result_data = async_to_sync(run_bpi_cheque_inquiry_logic)(national_id, sayad_id, id_type)
return JsonResponse(result_data)
except Exception as e:
logger.error(f"Error in BPI Check Registration Inquiry: {str(e)}", exc_info=True)
return JsonResponse({"success": False, "error": "خطای سرور در برقراری ارتباط با سامانه بانک."})
+497
View File
@@ -0,0 +1,497 @@
import datetime
import os
from django.db import models
from django.db.models import Sum, Q
from django.shortcuts import redirect, render, get_object_or_404
from django.core.paginator import Paginator
from django.views.decorators.csrf import csrf_exempt
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
from persiantools.jdatetime import JalaliDate
from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor,
FactorIteam, Article, Sarresid, Action, CacheTable, SMSType)
from ..sms.enqueue import enqueue_sms
from ..sms.phones import (customer_phone_entries, normalize_primary_values,
save_primary_normalized)
from ..sms.recipients import resolve_recipients
from ..sms.rules import customer_toggle_map
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@csrf_exempt
@login_required(login_url='/panel/p_login/')
def customers(request):
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
searchStatus = True
full_path = request.get_full_path()
# مشتریان غیرفعال هرگز نمایش داده نمی‌شوند — نه در فهرست کامل و نه در جستجو.
all_customers = Customer.objects.filter(is_active=True)
if searchOn == 'code':
all_customers = all_customers.filter(idCode__contains=searchVal)
elif searchOn == 'phone':
all_customers = all_customers.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal))
elif searchOn == 'name':
all_customers = all_customers.filter(name_family__contains=searchVal)
elif searchOn == 'address':
all_customers = all_customers.filter(address__contains=searchVal)
elif searchOn == 'laghv':
all_customers = all_customers.filter(laghv_bazdid=True)
else:
searchStatus = False
all_customers = all_customers.order_by('-create_date')
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
all_customers = paginator.get_page(page_number)
data = {'customersD': all_customers, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn}
return render(request, 'admin_panel/customers.html', data)
@login_required(login_url='/panel/p_login/')
def customersSearch(request, searchVal,searchOn):
searchStatus = True
# همان قاعدهٔ فهرست اصلی: مشتری غیرفعال در هیچ نتیجه‌ای نمایش داده نمی‌شود.
all_customers = Customer.objects.filter(is_active=True)
if searchOn == 'code':
all_customers = all_customers.filter(idCode__contains=searchVal)
elif searchOn == 'phone':
all_customers = all_customers.filter(phone1__contains=searchVal)
elif searchOn == 'name':
all_customers = all_customers.filter(name_family__contains=searchVal)
elif searchOn == 'address':
all_customers = all_customers.filter(address__contains=searchVal)
else:
searchStatus = False
all_customers = all_customers.order_by('-create_date')
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
all_customers = paginator.get_page(page_number)
data = {'customersD': all_customers, 'searchStatus': searchStatus}
return render(request, 'admin_panel/customers.html', data)
@login_required(login_url='/panel/p_login/')
def customer(request ,c_id):
customer = Customer.objects.get(id=c_id)
categorys = ProductCategory.objects.all()
factorha = Factor.objects.filter(customer__id = c_id)
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha}
return render(request, 'admin_panel/customerID.html', data)
@login_required(login_url='/panel/p_login/')
def customerNew(request ,c_id):
customer = Customer.objects.get(id=c_id)
categorys = ProductCategory.objects.all()
factorha = Factor.objects.filter(customer__id = c_id)
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha}
return render(request, 'admin_panel/customerNew.html', data)
@login_required(login_url='/panel/p_login/')
def customerNew2(request ,c_id):
customer = get_object_or_404(Customer, id=c_id, is_active=True)
categorys = ProductCategory.objects.all()
factorha = Factor.objects.filter(customer__id = c_id)
debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum']
mandehesabCs = 0
try:
if (debit - credit) < 0:
mandehesabCs = abs(debit - credit)
except:
pass
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
sms_toggles = customer_toggle_map(customer)
# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (PhoneNumber و phone1/2/3 قدیمی).
sms_phone_entries = customer_phone_entries(customer)
sms_effective = {n for n in resolve_recipients(customer)}
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
'sms_toggles': sms_toggles, 'sms_phone_entries': sms_phone_entries,
'sms_effective': sms_effective,
'sms_recipients': sorted(sms_effective),
}
return render(request, 'admin_panel/customerNew2.html', data)
@login_required(login_url='/panel/p_login/')
def customerFactors(request, c_id):
customer = get_object_or_404(Customer, id=c_id)
categorys = ProductCategory.objects.all()
# کوئری پایه برای دریافت همه فاکتورهای این مشتری
factor_qs = Factor.objects.filter(customer=customer)
# دریافت پارامترهای فیلتر و جستجو از آدرس URL
search_query = request.GET.get('search_factor', '').strip()
filter_status = request.GET.get('status', 'all')
filter_type = request.GET.get('type', 'all')
az_tarikh = request.GET.get('az_tarikh', '').strip()
ta_tarikh = request.GET.get('ta_tarikh', '').strip()
# ۱. اعمال فیلتر جستجو (شناسه فاکتور یا متن توضیحات)
if search_query:
if search_query.isdigit():
factor_qs = factor_qs.filter(models.Q(id=int(search_query)) | models.Q(desc__icontains=search_query))
else:
factor_qs = factor_qs.filter(models.Q(desc__icontains=search_query))
# ۲. اعمال فیلتر وضعیت پرداخت و حذف نرم
if filter_status != 'all':
if filter_status == 'deleted':
factor_qs = factor_qs.filter(is_active=False)
else:
factor_qs = factor_qs.filter(pay_status=filter_status, is_active=True)
# ۳. اعمال فیلتر نوع فاکتور (نصب، خدمات، گارانتی)
if filter_type != 'all':
factor_qs = factor_qs.filter(FactorType=filter_type)
# ۴. اعمال فیلتر تاریخ شروع (از تاریخ)
if az_tarikh:
az_gregorian = _parse_jalali_date(az_tarikh)
if az_gregorian:
az_datetime = datetime.datetime.combine(az_gregorian, datetime.time.min)
factor_qs = factor_qs.filter(create_date__gte=az_datetime)
# ۵. اعمال فیلتر تاریخ پایان (تا تاریخ)
if ta_tarikh:
ta_gregorian = _parse_jalali_date(ta_tarikh)
if ta_gregorian:
ta_datetime = datetime.datetime.combine(ta_gregorian, datetime.time.max)
factor_qs = factor_qs.filter(create_date__lte=ta_datetime)
# مرتب‌سازی بر اساس آخرین تاریخ ایجاد
factorha = factor_qs.order_by('-create_date')
dateNow = datetime.datetime.now().date
data = {
'customer': customer,
'categorys': categorys,
'dateNow': dateNow,
'factorha': factorha,
'search_query': search_query,
'filter_status': filter_status,
'filter_type': filter_type,
'az_tarikh': az_tarikh,
'ta_tarikh': ta_tarikh
}
return render(request, 'admin_panel/customerIDFactor.html', data)
@login_required(login_url='/panel/p_login/')
def customerFactor(request ,c_id, f_id):
customer = Customer.objects.get(id=c_id)
categorys = ProductCategory.objects.all()
factorO = Factor.objects.get(customer=customer, id=f_id)
fiteam = FactorIteam.objects.filter(factor=factorO)
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorO': factorO, 'fiteam': fiteam}
return render(request, 'admin_panel/customerFactor.html', data)
def productsJson(request):
from django.core import serializers
from django.http import JsonResponse
all_prd = Product.objects.all()
all_prd = serializers.serialize("json", all_prd)
return JsonResponse(all_prd,safe = False)
def ActionJson(request, action_id):
from django.core import serializers
from django.http import JsonResponse
AcJson = Action.objects.filter(id=action_id)
AcJson = serializers.serialize("json", AcJson)
return JsonResponse(AcJson,safe = False)
def CustomerJson(request, c_id):
from django.core import serializers
from django.http import JsonResponse
customerJson = Customer.objects.filter(id=c_id)
customerJson = serializers.serialize("json", customerJson)
return JsonResponse(customerJson,safe = False)
@login_required(login_url='/panel/p_login/')
def customersAdd(request):
an_name = request.POST.get('an_name', '')
an_address = request.POST.get('an_address', '')
installDate = request.POST.get('installDate', '')
idnCode = request.POST.get('idnCode', '')
# آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی»
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
year, month, day = map(int, installDate.split('/'))
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
newCs = Customer()
newCs.name_family = an_name
newCs.address = an_address
newCs.installDate = installDate_gregorian
newCs.idCode = idnCode
# حفظ سازگاری با کدهای قدیمی (ریختن ۳ شماره اول در فیلدهای قدیمی)
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
# شماره‌های «اصلی» پیامک، بر مبنای مقدارِ نرمال‌شدهٔ خودِ شماره‌ها (پایدار در بازسازی‌ها).
newCs.sms_primary_phones = normalize_primary_values(
phones_list[i] for i in primary_indices if i < len(phones_list)
)
newCs.save()
# ذخیره شماره‌ها به صورت نامحدود در جدول PhoneNumber
for item in valid_phones:
PhoneNumber.objects.create(
customer=newCs, title=item['title'], phone_number=item['phone'],
)
# پیامک خوش‌آمدگویی (در صورت فعال بودن قالب و تنظیم مشتری)
enqueue_sms(
customer=newCs, sms_type=SMSType.WELCOME,
context={'name': newCs.name_family},
dedup_key=f'welcome:{newCs.id}',
created_by=request.user,
)
return redirect('/panel/customers/')
@login_required(login_url='/panel/p_login/')
def customerEdit(request, c_id):
# این مسیر فقط endpoint فرم ویرایش است؛ دسترسی مستقیم با GET نباید ۵۰۰ بدهد.
if request.method != 'POST':
return redirect('/panel/customer/' + str(c_id))
an_name = request.POST.get('an_name', '')
an_address = request.POST.get('an_address', '')
installDate = request.POST.get('installDate', '')
idnCode = request.POST.get('idnCode', '')
laghv_bazdid = request.POST.get('laghv_bazdid', False)
# آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی»
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
year, month, day = map(int, installDate.split('/'))
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
newCs = get_object_or_404(Customer, id=c_id)
newCs.name_family = an_name
newCs.address = an_address
newCs.installDate = installDate_gregorian
newCs.idCode = idnCode
newCs.laghv_bazdid = True if laghv_bazdid == 'true' else False
# فقط وقتی فرم فیلد توضیحات را فرستاده باشد مقدارش عوض می‌شود؛ فرم‌های قدیمی
# که این فیلد را ندارند نباید یادداشت‌های پاک‌سازی را پاک کنند.
if 'notes' in request.POST:
newCs.notes = request.POST.get('notes', '')
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری (تنها وقتی شماره‌ای ارسال شده باشد)
if valid_phones:
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
newCs.save()
# بروزرسانی جدول شماره‌ها فقط در صورت ارسال شماره‌های جدید.
# اگر فرم شماره‌ای نفرستاده باشد (فیلدهای غیرفعال)، شماره‌های فعلی دست‌نخورده می‌مانند.
if valid_phones:
PhoneNumber.objects.filter(customer=newCs).delete()
for item in valid_phones:
PhoneNumber.objects.create(
customer=newCs, title=item['title'], phone_number=item['phone'],
)
# اگر فرم ویرایش ردیف‌های «اصلی» را مشخص کرده باشد، همان‌ها ذخیره می‌شوند
# (مبتنی بر خودِ شماره، نه ایندکس رکورد).
if primary_indices:
save_primary_normalized(
newCs, [phones_list[i] for i in primary_indices if i < len(phones_list)],
)
return redirect('/panel/customer/'+str(c_id))
@login_required(login_url='/panel/p_login/')
def entezar(request):
a = datetime.datetime.now()
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
searchStatus = True
full_path = request.get_full_path()
from django.db.models import Q
bazdid = os.environ.get('bazdid', 9)
bazdid = int ( int(bazdid) * 4.3 )
dateTime6MonthAgo = a - datetime.timedelta(weeks=bazdid)
''' customers = Customer.objects.filter(name_family__isnull=False)
m6mbazdid = []
for customerObj in customers:
try:
actionAa = Action.objects.filter(customer=customerObj, actionType='bazdid', status='finish').last()
if actionAa.status == 'finish':
customerObj.last_bazdid_dt = actionAa.actionDateTime
customerObj.save()
if actionAa.actionDateTime.date() < dateTime6MonthAgo.date():
m6mbazdid.append(actionAa)
except:
pass '''
if searchOn == 'address':
print("addressaddressaddressaddressaddress")
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False, address__contains=searchVal).order_by('last_bazdid_dt')
else:
searchStatus = False
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).order_by('last_bazdid_dt')
#customers_objj = Customer.objects.filter(name_family__isnull=False, last_bazdid_dt__lte=dateTime6MonthAgo)
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
entezar_bazdid.value = Customer.objects.filter(
Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).count()
entezar_bazdid.save()
except:
pass
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
customerHa = paginator.get_page(page_number)
return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.DelCustomer', raise_exception=True)
def customerDel(request, c_id):
customer_obj = get_object_or_404(Customer, id=c_id)
try:
# ۱. بررسی وجود هرگونه سابقه مالی/حسابداری (حتی با مانده صفر)
has_articles = Article.objects.filter(customer=customer_obj).exists()
# ۲. بررسی وجود هرگونه سابقه چک یا نسیه (حتی در صورت پاس شدن و تسویه کامل)
has_sarresids = Sarresid.objects.filter(customer=customer_obj).exists()
# ۳. بررسی وجود هرگونه فاکتور ثبت شده برای این مشتری
has_factors = Factor.objects.filter(customer=customer_obj).exists()
# ۴. بررسی وجود هرگونه سابقه اقدام، بازدید یا نظرسنجی
has_actions = Action.objects.filter(customer=customer_obj).exists()
# ۵. اگر هر کدام از این سوابق وجود داشته باشد، به هیچ عنوان اجازه غیرفعال‌سازی/حذف نمی‌دهیم
if has_articles or has_sarresids or has_factors or has_actions:
error_msg = f"عملیات ناموفق: مشتری '{customer_obj.name_family}' به دلیل داشتن سابقه در سیستم قابل حذف نیست! دلایل: "
reasons = []
if has_articles:
reasons.append("داشتن تراکنش مالی یا گردش حسابداری")
if has_sarresids:
reasons.append("داشتن سابقه چک یا نسیه (حتی تسویه شده)")
if has_factors:
reasons.append("داشتن فاکتور ثبت‌شده")
if has_actions:
reasons.append("داشتن سابقه اقدامات، بازدید یا نظرسنجی")
error_msg += "، ".join(reasons) + "."
# ارسال پیام خطا به تمپلیت و بازگرداندن کاربر به صفحه قبلی
messages.error(request, error_msg)
return redirect(request.META.get('HTTP_REFERER', '/panel/customers/'))
# ۶. در صورتی که مشتری کاملاً خام بود و هیچ سابقه‌ای در هیچ بخشی نداشت، حذف می‌شود
customer_obj.delete()
messages.success(request, f"مشتری '{customer_obj.name_family}' که فاقد هرگونه سابقه‌ای بود، با موفقیت از سیستم حذف شد.")
return redirect('/panel/customers/')
except Exception as e:
logger.error(f"Error in deleting customer {c_id}: {str(e)}", exc_info=True)
messages.error(request, "خطای سیستمی در بررسی سوابق و حذف مشتری رخ داد.")
return redirect(request.META.get('HTTP_REFERER', '/panel/customers/'))
@login_required(login_url='/panel/p_login/')
def customerHesabdari(request ,c_id):
customer = Customer.objects.get(id=c_id)
try:
debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum']
mandehesabCs = debit - credit
articls = Article.objects.filter(customer=customer).order_by('-createdDT')
except:
articls =None
mandehesabCs = 0
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'mandehesabCs': mandehesabCs, 'articls': articls}
return render(request, 'admin_panel/customerHesabDari.html', data)
@login_required(login_url='/panel/p_login/')
def sarresidh(request ,c_id):
customer = Customer.objects.get(id=c_id)
try:
all_sarresid = Sarresid.objects.filter(customer=customer).order_by('-sarresidDT')
except:
all_sarresid = None
data = {'customer': customer, 'all_sarresid': all_sarresid}
return render(request, 'admin_panel/customerSarresid.html', data)
@login_required(login_url='/panel/p_login/')
def customerHesabdariAdd(request ,c_id):
user_op = request.user
price = request.POST.get('price', None)
type = request.POST.get('type', None)
typePy = request.POST.get('typePy', None)
desc = request.POST.get('desc', None)
nahvePardakhat = request.POST.get('nahvePardakhat', None)
newArticl = Article()
newArticl.customer = Customer.objects.get(id=c_id)
newArticl.desc = desc
newArticl.typePy = typePy
if type == 'credit':
newArticl.credit = price
newArticl.debit = 0
elif type == 'debit':
newArticl.debit = price
newArticl.credit = 0
newArticl.user = user_op
newArticl.nahvePardakht = nahvePardakhat
newArticl.save()
return redirect('/panel/customer/'+str(c_id)+'/hesabdari')
+465
View File
@@ -0,0 +1,465 @@
import json
from django.db import transaction, connection
from django.db.models import Sum, Q
from django.shortcuts import redirect, get_object_or_404
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.http import require_POST
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, permission_required
from persiantools.jdatetime import JalaliDate
from persiantools import digits
from ..models import (Customer, StoreRoom, Factor, FactorIteam, Product,
ProductInStore, Article, Sarresid)
from ._helpers import logger, _safe_int, _parse_jalali_date, _parse_timestamp, InventoryException
@login_required(login_url='/panel/p_login/')
def newFactor(request, c_id):
prdsList = request.POST.get('prdsList', '')
sarResidDate = request.POST.get('sarResidDate', None)
pardakhti = request.POST.get('pardakhti', None)
#return HttpResponse(pardakhti)
if pardakhti:
pardakhti=int(pardakhti)
if pardakhti>=1:
pass
else:
pardakhti = None
#return HttpResponse(sarResidDate)
try:
sarResidDate = digits.fa_to_en(digits.ar_to_fa(sarResidDate))
#return HttpResponse(sarResidDate)
sarResidDate = sarResidDate.split("/")
#return HttpResponse(sarResidDate[2])
sarResidDate = JalaliDate(int(sarResidDate[0]), int(sarResidDate[1]), int(sarResidDate[2])).to_gregorian()
#return HttpResponse(sarResidDate)
except:
sarResidDate = None
#return HttpResponse(sarResidDate[1])
#sarResidDate = JalaliDate(sarResidDate[0], sarResidDate[1], sarResidDate[2]).to_gregorian()
payType = request.POST.get('payType', '')
FactorDate = request.POST.get('FactorDate', '')
FactorType = request.POST.get('FactorType', '')
FactorDesc = request.POST.get('FactorDesc', '')
Factorfail = False
customerObj=Customer.objects.get(id=c_id)
storeUser = StoreRoom.objects.get(user__id=request.user.id)
import json
prdsList = json.loads(prdsList)
sum_price = 0
for prd in prdsList:
if prd['garanty_Esh'] == ' ' or prd['garanty_Esh'] == '' or prd['garanty_Esh'] == None:
sum_price += prd['prd_count'] * prd['price']
#return HttpResponse(prd_code)
try:
newFactor = Factor()
newFactor.customer= customerObj
newFactor.amount = sum_price
if payType in ('naghd', 'kartKhan', 'cardToCard'):
newFactor.pay_status = 'paid'
else:
newFactor.pay_status = 'unpaid'
newFactor.FactorType = FactorType
newFactor.PayType = payType
newFactor.desc = FactorDesc
if sarResidDate != None:
newFactor.deadline = sarResidDate
newFactor.user = User.objects.get(id=request.user.id)
newFactor.save()
except:
try:
Factorfail=True
newFactor.delete()
except:
pass
if Factorfail != True:
#accFactor To article
is_naghd = False
articleHesabDari = Article()
articleHesabDari.debit = newFactor.amount
articleHesabDari.credit = 0
articleHesabDari.customer = customerObj
articleHesabDari.factor = newFactor
if payType in ('naghd', 'kartKhan', 'cardToCard'):
is_naghd = True
articleHesabDari.type = payType
articleHesabDari.desc = str(FactorType) + " " + str(customerObj.name_family) + " طی فاکتور شماره: " + str(newFactor.id)
articleHesabDari.save()
if is_naghd:
articleHesabDari2 = Article()
articleHesabDari2.debit = 0
articleHesabDari2.credit = newFactor.amount
articleHesabDari2.customer = customerObj
articleHesabDari2.type = payType
articleHesabDari2.factor = newFactor
articleHesabDari2.desc = str(FactorType) + " " + str(customerObj.name_family) + " تسویه نقدی فاکتور شماره: " + str(newFactor.id)
articleHesabDari2.save()
elif pardakhti != None and pardakhti!='' and pardakhti != ' ':
articleHesabDari2 = Article()
articleHesabDari2.debit = 0
articleHesabDari2.credit = pardakhti
articleHesabDari2.customer = customerObj
articleHesabDari2.type = payType
articleHesabDari2.factor = newFactor
articleHesabDari2.desc = str(customerObj.name_family) + " پرداخت نقد در محل برا فاکتور " + str(newFactor.id)
articleHesabDari2.save()
for prd in prdsList:
try:
fIteams = FactorIteam()
fIteams.factor = newFactor
if prd['prd_id'] == 9999:
Gproduct = None
fIteams.product = Gproduct
fIteams.product_name = prd['product_name']
fIteams.num = prd['prd_count']
fIteams.amount = int(prd['priceKol'])
else:
Gproduct = Product.objects.get(id=prd['prd_id'])
fIteams.product = Gproduct
fIteams.product_name = Gproduct.product_name
fIteams.num = prd['prd_count']
fIteams.amount = prd['prd_count'] * Gproduct.product_price
fIteams.garantyEsh = prd['garanty_Esh']
fIteams.save()
except:
Factorfail = True
newFactor.delete()
break
if Factorfail != True:
for prd in prdsList:
if prd['prd_id'] != 9999:
pInStore = ProductInStore()
sRoom = storeUser
productO = Product.objects.get(id=prd['prd_id'])
pInStore.StoreRoom = sRoom
pInStore.Product = productO
pInStore.factor = newFactor
productS_in = ProductInStore.objects.filter(Product=productO, StoreRoom=sRoom, year=1400).aggregate(Sum('s_in'))['s_in__sum']
productS_out = ProductInStore.objects.filter(Product=productO, StoreRoom=sRoom, year=1400).aggregate(Sum('s_out'))['s_out__sum']
mojodi_prd = productS_in - productS_out
if mojodi_prd >= prd['prd_count']:
pInStore.s_out = prd['prd_count']
if prd['garanty_Esh'] != ' ' and prd['garanty_Esh'] != '' and prd['garanty_Esh'] != None:
pInStore.desc = "خروج: " + str(prd['prd_count']) + "#"+str(prd['garanty_Esh'])+ "#" + str(customerObj.name_family) + " * " + str(request.user.first_name) + " " + str(request.user.last_name)
else:
pInStore.desc = "خروج: " + str(prd['prd_count']) + "#فروش" + "#" + str(customerObj.name_family) + " * " + str(request.user.first_name) + " " + str(request.user.last_name)
pInStore.save()
else:
return HttpResponse("موجودی کافی نیست، موجودی فعلی: "+ str(mojodi_prd))
return redirect('/panel/customer/'+str(c_id)+'/factors/')
@login_required(login_url='/panel/p_login/')
def newFactor2(request, c_id):
if request.method != 'POST':
return HttpResponseBadRequest("متد ارسالی باید POST باشد.")
user_op = request.user
try:
prds_list = json.loads(request.POST.get('prdsList') or '[]')
json_pardakht_ha = json.loads(request.POST.get('PardakhtHa') or '{}')
except json.JSONDecodeError:
return HttpResponseBadRequest("فرمت داده‌های ارسالی JSON نامعتبر است.")
factor_type = request.POST.get('FactorType', '')
factor_desc = request.POST.get('FactorDesc', '')
sar_resid_date = _parse_jalali_date(request.POST.get('sarResidDate'))
customer_obj = get_object_or_404(Customer, id=c_id)
store_room = get_object_or_404(StoreRoom, owners=user_op)
# رفع مشکل متنی/عددی در استخراج شناسه کالاها
product_ids = []
for item in prds_list:
p_id = item.get('prd_id')
if p_id and _safe_int(p_id) != 9999:
product_ids.append(_safe_int(p_id))
products_by_id = {p.id: p for p in Product.objects.filter(id__in=product_ids)}
sum_price = sum(
_safe_int(item.get('prd_count')) * _safe_int(item.get('price'))
for item in prds_list
if not str(item.get('garanty_Esh', '')).strip()
)
try:
with transaction.atomic():
new_factor = Factor.objects.create(
customer=customer_obj,
amount=sum_price,
FactorType=factor_type,
desc=factor_desc,
deadline=sar_resid_date,
user=user_op
)
Article.objects.create(
debit=sum_price,
credit=0,
customer=customer_obj,
factor=new_factor,
desc=f"{customer_obj.name_family} فاکتور شماره: {new_factor.id}",
user=user_op
)
payment_map = {
'l_naghd': 'نقد',
'l_kartbekart': 'کارت به کارت',
'l_kartKhan': 'کارت خوان',
'l_takhfif': 'تخفیف'
}
for key, amount_raw in json_pardakht_ha.items():
amount = _safe_int(amount_raw)
if amount <= 0:
continue
if key in payment_map:
nahve_pardakht = payment_map[key]
Article.objects.create(
debit=0,
credit=amount,
customer=customer_obj,
factor=new_factor,
desc=f"{nahve_pardakht} {customer_obj.name_family} فاکتور شماره: {new_factor.id}",
nahvePardakht=nahve_pardakht,
payStatus="true",
user=user_op
)
elif key in ['l_nesiye', 'l_checkPrice']:
is_nesiye = (key == 'l_nesiye')
type_str = 'nesiye' if is_nesiye else 'check'
ts_key = 'l_nesiyeSarResid' if is_nesiye else 'l_checkPriceSarResid'
sar_resid_dt = _parse_timestamp(json_pardakht_ha.get(ts_key))
Sarresid.objects.create(
customer=customer_obj,
price=amount,
payStatus="false",
type=type_str,
user=user_op,
factor=new_factor,
sarresidDT=sar_resid_dt
)
factor_items_pool = []
user_fullname = f"{user_op.first_name} {user_op.last_name}".strip() or user_op.username
for item in prds_list:
# تبدیل امن شناسه به عدد
prd_id = _safe_int(item.get('prd_id'))
prd_count = _safe_int(item.get('prd_count'))
garanty_esh = item.get('garanty_Esh', '')
if prd_id == 9999:
factor_items_pool.append(FactorIteam(
factor=new_factor,
product=None,
product_name=item.get('product_name', ''),
num=prd_count,
amount=_safe_int(item.get('priceKol')),
garantyEsh=garanty_esh
))
else:
product_obj = products_by_id.get(prd_id)
if not product_obj:
raise InventoryException(f"خطا در دریافت محصول: محصولی با شناسه {prd_id} در سیستم وجود ندارد.")
factor_items_pool.append(FactorIteam(
factor=new_factor,
product=product_obj,
product_name=product_obj.product_name,
num=prd_count,
amount=prd_count * _safe_int(product_obj.product_price),
garantyEsh=garanty_esh
))
FactorIteam.objects.bulk_create(factor_items_pool, batch_size=500)
# فرآیند کسر از انبار (Enterprise Level)
for item in prds_list:
prd_id = _safe_int(item.get('prd_id'))
if prd_id == 9999:
continue
prd_count = _safe_int(item.get('prd_count'))
garanty_esh = item.get('garanty_Esh', '')
product_obj = products_by_id.get(prd_id)
if not product_obj:
continue
# ۱. کوئری پایه
qs = ProductInStore.objects.filter(
Product=product_obj,
StoreRoom=store_room,
year=1400
)
# ۲. قفل دیتابیس هوشمند و سازگار با تمام پایگاه‌های داده
if connection.features.has_select_for_update:
qs = qs.select_for_update()
# ۳. بارگذاری امن در رم برای جلوگیری از خطای Aggregate در دیتابیس قفل‌شده
locked_records = list(qs)
# ۴. محاسبه دقیق و پرسرعت در پایتون
total_in = sum(rec.s_in for rec in locked_records if rec.s_in)
total_out = sum(rec.s_out for rec in locked_records if rec.s_out)
mojodi_prd = total_in - total_out
if mojodi_prd < prd_count:
# استفاده از متن دقیق خطای قبلی جهت حفظ سازگاری با فرانت‌اند
raise InventoryException(f"موجودی کافی نیست، موجودی فعلی {product_obj.product_name}: {mojodi_prd}")
# ۵. تشخیص نوع تراکنش برای سیستم گزارش‌گیری جدید انبار
if garanty_esh and str(garanty_esh).strip():
desc_text = f"خروج: {prd_count}#{garanty_esh}#{customer_obj.name_family} * {user_fullname}"
if "اشانتیون" in str(garanty_esh):
m_type = ProductInStore.MovementType.PROMOTION
else:
m_type = ProductInStore.MovementType.WARRANTY
else:
desc_text = f"خروج: {prd_count}#فروش#{customer_obj.name_family} * {user_fullname}"
m_type = ProductInStore.MovementType.SALE
# ۶. ثبت تراکنش خروجی انبار
ProductInStore.objects.create(
StoreRoom=store_room,
Product=product_obj,
factor=new_factor,
s_out=prd_count,
desc=desc_text,
movement_type=m_type, # ثبت ساختاریافته نوع خروج برای سرعت در گزارش‌گیری
year=1400
)
except InventoryException as e:
return HttpResponse(str(e), status=400)
except Exception as e:
logger.error(f"Error creating factor for customer {c_id}: {str(e)}", exc_info=True)
return HttpResponse("خطایی غیرمنتظره در سرور رخ داد. اطلاعات ذخیره نشد.", status=500)
return redirect(f'/panel/customer/{c_id}/factors/')
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.DelFactor', raise_exception=True)
@require_POST
def FactorDel(request, c_id, f_id):
customer_obj = get_object_or_404(Customer, id=c_id)
factor_initial = get_object_or_404(Factor, id=f_id, customer=customer_obj)
if factor_initial.pay_status == 'cancell' or not factor_initial.is_active:
return HttpResponseBadRequest("این فاکتور قبلاً باطل یا برگشت داده شده است.")
user_fullname = f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username
cancel_reason = request.POST.get('cancel_reason', 'error')
payment_action = request.POST.get('payment_action', 'keep')
if cancel_reason == 'return':
reason_text = "برگشت از فروش (مرجوعی مشتری)"
inv_movement = ProductInStore.MovementType.RETURN
else:
reason_text = "اصلاح خطای کاربری"
inv_movement = ProductInStore.MovementType.ERROR_CORRECTION
try:
with transaction.atomic():
if connection.features.has_select_for_update:
factor = Factor.objects.select_for_update().get(pk=factor_initial.pk)
else:
factor = Factor.objects.get(pk=factor_initial.pk)
if factor.pay_status == 'cancell' or not factor.is_active:
return HttpResponseBadRequest("خطای همزمانی: این فاکتور در لحظه فرستادن درخواست باطل شده بود.")
factor.is_active = False
factor.pay_status = 'cancell'
factor.save(update_fields=['is_active', 'pay_status'])
# واکشی اسناد پرداخت قبل از ایجاد سند جدید (جلوگیری از باگ تکرار سند)
payments_to_reverse = []
if payment_action == 'reverse' or cancel_reason == 'error':
payments_to_reverse = list(Article.objects.filter(
Q(debit=0) | Q(debit__isnull=True),
factor=factor,
credit__gt=0
))
# ۱. صدور سند معکوس مالی برای کل مبلغ فاکتور
factor_amount = int(factor.amount or 0)
if factor_amount > 0:
Article.objects.create(
debit=0,
credit=factor_amount,
customer=customer_obj,
factor=factor,
type=factor.PayType,
desc=f"برگشت از فروش و ابطال فاکتور شماره {factor.id} (مرجوعی کالا توسط مشتری) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"اصلاح خطای سیستم: ابطال فاکتور اشتباه شماره {factor.id} (بدون جابجایی واقعی کالا) - کاربر: {user_fullname}",
user=request.user,
payStatus="true"
)
# ۲. خنثی کردن پرداخت‌های اولیه فاکتور
for p in payments_to_reverse:
Article.objects.create(
debit=p.credit,
credit=0,
customer=customer_obj,
factor=factor,
type=p.type,
nahvePardakht=p.nahvePardakht,
desc=f"عودت وجه پرداخت شده / ابطال رسید فاکتور {factor.id} (به دلیل مرجوعی کالا) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"اصلاح خطای سیستم: ابطال رسید پرداخت فاکتور اشتباه شماره {factor.id} - کاربر: {user_fullname}",
user=request.user,
payStatus="true"
)
# ۳. 🛠️ اصلاح انبار (حل باگ عدم تغییر موجودی سیستم)
stock_movements = list(ProductInStore.objects.filter(factor=factor, s_out__gt=0))
for movement in stock_movements:
pInStore = ProductInStore(
StoreRoom=movement.StoreRoom,
Product=movement.Product,
factor=factor,
s_in=movement.s_out, # برگرداندن تعداد کسر شده به ستون ورودی
s_out=0,
year=movement.year, # 💡 فیکس قطعی: استفاده دقیق از سال مالی تراکنش اصلی (۱۴۰۰) جهت همخوانی با توابع سیستم
desc=f"ورود به انبار بابت برگشت از فروش کالا (فاکتور {factor.id}) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"ورود به انبار بابت اصلاح خطای ثبت فاکتور {factor.id} - کاربر: {user_fullname}",
movement_type=inv_movement
)
pInStore.save() # فراخوانی متد save مدل شما که محاسبات تاریخچه و موجودی لحظه‌ای را انجام می‌دهد
# ۴. ابطال اقساط و سررسیدهای پاس نشده
Sarresid.objects.filter(factor=factor, payStatus="false").update(
payStatus="cancell",
desc=f"ابطال بابت {reason_text} فاکتور {factor.id}"
)
logger.info(f"Factor {factor.id} successfully cancelled. Reason: {cancel_reason}, Payment: {payment_action}")
except Exception as e:
logger.error(f"Critical error in FactorDel for factor {f_id}: {str(e)}", exc_info=True)
return HttpResponse("خطای سیستمی در فرآیند ابطال و صدور اسناد اصلاحی رخ داد.", status=500)
return redirect('admin_panel:customerFactors', c_id=c_id)
+974
View File
@@ -0,0 +1,974 @@
import datetime
import json
from django.db import transaction
from django.db.models import (Sum, Q, Count, Avg, Min, F, Subquery, OuterRef,
IntegerField, Value, Case, When)
from django.db.models.functions import Coalesce
from django.shortcuts import redirect, render, get_object_or_404
from django.http import HttpResponse, HttpResponseBadRequest
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
from django.views.decorators.http import require_POST
from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, permission_required
from persiantools.jdatetime import JalaliDate
from ..models import (Article, Sarresid, Customer, Factor, ProductInStore,
StoreRoom, Action)
from ._helpers import logger, _safe_int
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def report(request):
nahvePardakhat = [
{'fa': 'تخفیف', 'en': 'تخفیف'},
{'fa': 'نسیه', 'en': 'نسیه'},
{'fa': 'چک', 'en': 'چک'},
{'fa': 'کارتخوان', 'en': 'کارت خوان'},
{'fa': 'کارت به کارت', 'en': 'کارت به کارت'},
{'fa': 'نقدی', 'en': 'نقد'},
]
userHa = User.objects.filter()
if request.method == "GET":
nahvePardakht = request.GET.get("nahvepardakht", None)
user = request.GET.get("user", None)
azTarikh = request.GET.get("azTarikh", None)
taTarikh = request.GET.get("taTarikh", None)
pagenumber = request.GET.get("page", None)
if azTarikh and nahvePardakht:
query_data = {
'nahvePardakht': nahvePardakht,
'user': user,
'azTarikh': azTarikh,
'taTarikh': taTarikh,
}
azDateTime = datetime.datetime.fromtimestamp(int(azTarikh) / 1e3)
azDateTime = azDateTime.strftime('%Y-%m-%d 00:00:00')
taDateTime = datetime.datetime.fromtimestamp(int(taTarikh) / 1e3)
taDateTime = taDateTime.strftime('%Y-%m-%d 23:59:59')
print("b")
user_a = User.objects.get(username=user)
print(user_a)
if nahvePardakht == "نسیه" or nahvePardakht == "چک":
if nahvePardakht == 'نسیه':
typeSarresid = 'nesiye'
elif nahvePardakht == 'چک':
typeSarresid = 'check'
sumSarresidha = Sarresid.objects.filter(
type=typeSarresid,
user=user_a,
createdDT__lte=taDateTime,
createdDT__gte=azDateTime,
payStatus='false'
).aggregate(Sum('price'))
sarresidha = Sarresid.objects.filter(
type=typeSarresid,
user=user_a,
createdDT__lte=taDateTime,
createdDT__gte=azDateTime,
payStatus='false'
)
paginator = Paginator(sarresidha, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
all_sarresidha = paginator.get_page(pagenumber)
data = {'sarresidha': all_sarresidha ,'sumSarresidha': sumSarresidha, 'nahvePardakhat': nahvePardakhat, 'userHa':userHa, 'query_data': query_data}
return render(request, 'admin_panel/reportCheckNesiye.html', data)
else:
sumAricles = Article.objects.filter(
nahvePardakht=nahvePardakht,
user=user_a,
createdDT__lte=taDateTime,
createdDT__gte=azDateTime,
).aggregate(Sum('credit'))
aricles1 = Article.objects.filter(
nahvePardakht=nahvePardakht,
user=user_a,
createdDT__lte=taDateTime,
createdDT__gte=azDateTime,
)
paginator = Paginator(aricles1, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
all_aricles = paginator.get_page(page_number)
data = {'aricles': all_aricles,'sum_credit': sumAricles, 'nahvePardakhat': nahvePardakhat, 'userHa':userHa, 'query_data': query_data}
return render(request, 'admin_panel/report.html', data)
else:
data = {'nahvePardakhat': nahvePardakhat, 'userHa':userHa}
return render(request, 'admin_panel/report.html', data)
data = {'nahvePardakhat': nahvePardakhat, 'userHa':userHa}
return render(request, 'admin_panel/report.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def report_debtors_no_vosol(request):
now = datetime.datetime.now()
# --- ۱. دریافت و اعتبارسنجی پارامترهای فیلتر از فرانت‌اند ---
try:
days_threshold = int(request.GET.get('days', 20))
if days_threshold < 0:
days_threshold = 20
except ValueError:
days_threshold = 20
filter_type = request.GET.get('filter_type', 'all') # all, never, expired
sort_field = request.GET.get('sort', 'amount') # amount, oldest_date, last_action
sort_dir = request.GET.get('dir', 'desc') # asc, desc
threshold_date = now - datetime.timedelta(days=days_threshold)
# --- ۲. ساخت Subquery های فوق‌سریع و ایمن ---
sq_debit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('debit')).values('s')
sq_credit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('credit')).values('s')
sq_overdue_amt = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(s=Sum('price')).values('s')
sq_oldest_date = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(min_dt=Min('sarresidDT')).values('min_dt')
sq_overdue_count = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(c=Count('id')).values('c')
# استخراج آخرین سابقه اکشن وصول
latest_vosol_qs = Action.objects.filter(
customer=OuterRef('pk'), actionType='vosol'
).order_by('-createdDT')
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
base_qs = Customer.objects.filter(is_active=True).annotate(
t_debit=Coalesce(Subquery(sq_debit, output_field=IntegerField()), Value(0)),
t_credit=Coalesce(Subquery(sq_credit, output_field=IntegerField()), Value(0)),
overdue_amount=Coalesce(Subquery(sq_overdue_amt, output_field=IntegerField()), Value(0)),
overdue_count=Coalesce(Subquery(sq_overdue_count, output_field=IntegerField()), Value(0)),
oldest_overdue_date=Subquery(sq_oldest_date),
last_vosol_date=Subquery(latest_vosol_qs.values('createdDT')[:1]),
last_vosol_desc=Subquery(latest_vosol_qs.values('desc')[:1])
).annotate(
net_debt=F('t_debit') - F('t_credit')
).filter(
overdue_amount__gt=0 # شرط اساسی: فقط کسانی که معوقه سررسید گذشته دارند
)
# استخراج مشتریانی که نیازمند پیگیری هستند (هرگز پیگیری نشده‌اند یا موعد پیگیری‌شان گذشته است)
action_needed_qs = base_qs.filter(
Q(last_vosol_date__isnull=True) | Q(last_vosol_date__lt=threshold_date)
)
# --- ۴. محاسبه آمار دقیق (بدون خطا) برای داشبورد ---
# استفاده از Case/When برای جلوگیری کامل از خطای None در Aggregate
stats = action_needed_qs.aggregate(
never_count=Coalesce(Sum(Case(When(last_vosol_date__isnull=True, then=1), default=Value(0), output_field=IntegerField())), Value(0)),
never_amount=Coalesce(Sum(Case(When(last_vosol_date__isnull=True, then=F('overdue_amount')), default=Value(0), output_field=IntegerField())), Value(0)),
expired_count=Coalesce(Sum(Case(When(last_vosol_date__isnull=False, then=1), default=Value(0), output_field=IntegerField())), Value(0)),
expired_amount=Coalesce(Sum(Case(When(last_vosol_date__isnull=False, then=F('overdue_amount')), default=Value(0), output_field=IntegerField())), Value(0)),
)
# --- ۵. اعمال فیلتر کشویی (Dropdown) ---
if filter_type == 'never':
final_qs = action_needed_qs.filter(last_vosol_date__isnull=True)
elif filter_type == 'expired':
final_qs = action_needed_qs.filter(last_vosol_date__isnull=False)
else: # all
final_qs = action_needed_qs
# --- ۶. اعمال مرتب‌سازی (Sorting) پویا ---
if sort_field == 'amount':
order_expr = F('overdue_amount').desc() if sort_dir == 'desc' else F('overdue_amount').asc()
elif sort_field == 'oldest_date':
order_expr = F('oldest_overdue_date').desc(nulls_last=True) if sort_dir == 'desc' else F('oldest_overdue_date').asc(nulls_last=True)
elif sort_field == 'last_action':
order_expr = F('last_vosol_date').desc(nulls_last=True) if sort_dir == 'desc' else F('last_vosol_date').asc(nulls_first=True)
else:
order_expr = F('overdue_amount').desc()
final_qs = final_qs.order_by(order_expr)
# --- ۷. صفحه‌بندی ایمن ---
paginator = Paginator(final_qs, 50)
page_number = request.GET.get('page', 1)
try:
page_obj = paginator.page(page_number)
except (PageNotAnInteger, EmptyPage):
page_obj = paginator.page(1)
context = {
'page_obj': page_obj,
'page_name': 'debtors_no_vosol',
'stats': stats,
# پارامترهای کنترل پنل
'current_days': days_threshold,
'current_filter': filter_type,
'current_sort': sort_field,
'current_dir': sort_dir,
}
return render(request, 'admin_panel/report_debtors_no_vosol.html', context)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def inventory_movements_report(request):
"""
گزارش جامع گردش و خروج کالا از انبار به همراه فیلتر متنی نام کالا،
نوع تراکنش، کاربر، انبار و بازه زمانی شمسی.
"""
all_users = User.objects.all().order_by('first_name')
all_stores = StoreRoom.objects.all()
movement_choices = ProductInStore.MovementType.choices
# دریافت پارامترهای فیلتر از فرانت‌اند
selected_type = request.GET.get('movement_type', '')
selected_user = request.GET.get('user_id', '')
selected_store = request.GET.get('store_id', '')
selected_product_name = request.GET.get('product_name', '').strip() # فیلد جدید جستجوی نام کالا
az_tarikh = request.GET.get('azTarikh', '')
ta_tarikh = request.GET.get('taTarikh', '')
# ۱. کوئری پایه بهینه
movements_list = ProductInStore.objects.select_related(
'Product', 'StoreRoom', 'factor__customer', 'factor__user'
).filter(year=1400).order_by('-createdDT')
# ۲. اعمال فیلترهای پویا
if selected_type:
movements_list = movements_list.filter(movement_type=selected_type)
if selected_user:
movements_list = movements_list.filter(factor__user__id=_safe_int(selected_user))
if selected_store:
movements_list = movements_list.filter(StoreRoom__id=_safe_int(selected_store))
if selected_product_name:
# فیلتر متنی حساس نبودن به حروف بزرگ و کوچک (Case-Insensitive Contains)
movements_list = movements_list.filter(Product__product_name__icontains=selected_product_name)
# ۳. پردازش بازه زمانی
if az_tarikh:
try:
az_dt = datetime.datetime.fromtimestamp(int(az_tarikh) / 1000.0)
movements_list = movements_list.filter(createdDT__gte=az_dt.strftime('%Y-%m-%d 00:00:00'))
except (ValueError, TypeError):
pass
if ta_tarikh:
try:
ta_dt = datetime.datetime.fromtimestamp(int(ta_tarikh) / 1000.0)
movements_list = movements_list.filter(createdDT__lte=ta_dt.strftime('%Y-%m-%d 23:59:59'))
except (ValueError, TypeError):
pass
# ۴. محاسبه آماری سریع دیتابیس روی نتایج فیلتر شده
summary_stats = movements_list.aggregate(
total_out_qty=Sum('s_out'),
total_in_qty=Sum('s_in')
)
# ۵. صفحه‌بندی نتایج
paginator = Paginator(movements_list, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'all_users': all_users,
'all_stores': all_stores,
'movement_choices': movement_choices,
'summary_stats': summary_stats,
'filters': {
'movement_type': selected_type,
'user_id': _safe_int(selected_user) if selected_user else '',
'store_id': _safe_int(selected_store) if selected_store else '',
'product_name': selected_product_name, # ارسال فیلد جدید به قالب برای حفظ مقدار ورودی
'azTarikh': az_tarikh,
'taTarikh': ta_tarikh,
}
}
return render(request, 'admin_panel/report_inventory_movements.html', context)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def financial_dashboard(request):
"""
داشبورد هوش مالی و مدیریت نقدینگی (BI Dashboard)
مجهز به هسته حسابرسی ۳۶۰ درجه برای کشف تمامی مغایرت‌ها (بدهی رو هوا، چک‌های الکی، پیش‌دریافت‌ها)
"""
az_tarikh = request.GET.get('azTarikh', '')
ta_tarikh = request.GET.get('taTarikh', '')
az_dt = None
ta_dt = None
if az_tarikh:
try:
az_dt = datetime.datetime.fromtimestamp(int(az_tarikh) / 1000.0)
az_dt = az_dt.replace(hour=0, minute=0, second=0)
except (ValueError, TypeError):
pass
if ta_tarikh:
try:
ta_dt = datetime.datetime.fromtimestamp(int(ta_tarikh) / 1000.0)
ta_dt = ta_dt.replace(hour=23, minute=59, second=59)
except (ValueError, TypeError):
pass
anchor_dt = ta_dt if ta_dt else datetime.datetime.now()
# =========================================================================
# محاسبه روند ۱۲ ماهه غلتان (TTM)
# =========================================================================
jalali_months = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
trend_labels = []
trend_data = []
anchor_jd = JalaliDate(anchor_dt.date())
curr_y = int(anchor_jd.year)
curr_m = int(anchor_jd.month)
for _ in range(12):
if curr_m < 1:
curr_m = 12
curr_y -= 1
label = f"{jalali_months[curr_m - 1]} {curr_y}"
trend_labels.insert(0, label)
last_day = JalaliDate.days_in_month(curr_m, curr_y)
end_of_month_jd = JalaliDate(curr_y, curr_m, last_day)
end_of_month_gd = end_of_month_jd.to_gregorian()
end_dt_str = end_of_month_gd.strftime('%Y-%m-%d 23:59:59')
totals = Article.objects.filter(createdDT__lte=end_dt_str).aggregate(
t_deb=Sum('debit'),
t_cred=Sum('credit')
)
t_deb = totals['t_deb'] or 0
t_cred = totals['t_cred'] or 0
month_total_debt = t_deb - t_cred
trend_data.insert(0, month_total_debt)
curr_m -= 1
# =========================================================================
# فیلترهای پایه (داده‌های زمان‌دار و داشبورد)
# =========================================================================
sarresid_qs = Sarresid.objects.filter(factor__productinstore__year=1400).distinct()
factor_qs = Factor.objects.filter(productinstore__year=1400, is_active=True).distinct()
article_qs = Article.objects.filter(factor__productinstore__year=1400).distinct()
store_qs = ProductInStore.objects.filter(year=1400)
if az_dt:
sarresid_qs = sarresid_qs.filter(createdDT__gte=az_dt)
factor_qs = factor_qs.filter(create_date__gte=az_dt)
article_qs = article_qs.filter(createdDT__gte=az_dt)
store_qs = store_qs.filter(createdDT__gte=az_dt)
if ta_dt:
sarresid_qs = sarresid_qs.filter(createdDT__lte=ta_dt)
factor_qs = factor_qs.filter(create_date__lte=ta_dt)
article_qs = article_qs.filter(createdDT__lte=ta_dt)
store_qs = store_qs.filter(createdDT__lte=ta_dt)
# کارت‌های شاخص عملکرد عمومی
total_unpaid_debts = sarresid_qs.filter(payStatus="false").aggregate(Sum('price'))['price__sum'] or 0
total_sales_period = factor_qs.aggregate(Sum('amount'))['amount__sum'] or 0
overdue_checks_count = sarresid_qs.filter(payStatus="false", type="check", sarresidDT__lte=anchor_dt).count()
# =========================================================================
# 👑 هسته حسابرسی ۳۶۰ درجه (برگرفته از Ghost Hunter / Deep Audit)
# =========================================================================
# الف) تراز کل ریاضی سیستم
g_art = Article.objects.aggregate(d=Sum('debit'), c=Sum('credit'))
global_mande = (g_art['d'] or 0) - (g_art['c'] or 0)
g_sar = Sarresid.objects.filter(payStatus="false").aggregate(s=Sum('price'))['s'] or 0
global_diff = global_mande - g_sar
# ب) محاسبات Subquery فوق‌سریع برای تمام مشتریان
sq_debit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('debit')).values('s')
sq_credit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('credit')).values('s')
sq_sarresid = Sarresid.objects.filter(customer=OuterRef('pk'), payStatus="false").values('customer').annotate(s=Sum('price')).values('s')
base_customers = Customer.objects.filter(is_active=True).annotate(
t_debit=Coalesce(Subquery(sq_debit, output_field=IntegerField()), 0),
t_credit=Coalesce(Subquery(sq_credit, output_field=IntegerField()), 0),
t_sarresid=Coalesce(Subquery(sq_sarresid, output_field=IntegerField()), 0),
).annotate(
net_debt=F('t_debit') - F('t_credit') # تراز نهایی
).annotate(
diff=F('net_debt') - F('t_sarresid') # میزان کسر/اضافه سند
)
# 🔴 گروه 1: بدهکارانی که کسر سند/چک دارند (بدهی خطرناک روی هوا) [diff > 0]
group1_qs = base_customers.filter(diff__gt=0).order_by('-diff')
group1_count = group1_qs.count()
group1_total = sum(group1_qs.values_list('diff', flat=True)) or 0
group1_sample = group1_qs[:20]
# 🟡 گروه 2: چک پاس‌نشده در سیستم بیشتر از بدهی است (خطای ثبتی اپراتور) [diff < 0 AND net_debt >= 0]
group2_qs = base_customers.filter(diff__lt=0, net_debt__gte=0).annotate(
fake_amount=F('t_sarresid') - F('net_debt')
).order_by('-fake_amount')
group2_count = group2_qs.count()
group2_total = sum(group2_qs.values_list('fake_amount', flat=True)) or 0
group2_sample = group2_qs[:20]
# 🟢 گروه 3: پیش‌دریافت خالص (بستانکار است و هیچ چکی ندارد) [diff < 0 AND net_debt < 0 AND t_sarresid = 0]
group3_qs = base_customers.filter(diff__lt=0, net_debt__lt=0, t_sarresid=0).order_by('net_debt') # net_debt منفی است
group3_count = group3_qs.count()
group3_total = sum(group3_qs.values_list('net_debt', flat=True)) or 0
group3_sample = group3_qs[:20]
# 🟣 گروه 4: عجیب‌ترین حالت! مشتری بستانکار است، اما چک پاس‌نشده هم در سیستم دارد! [diff < 0 AND net_debt < 0 AND t_sarresid > 0]
group4_qs = base_customers.filter(diff__lt=0, net_debt__lt=0, t_sarresid__gt=0).order_by('net_debt')
group4_count = group4_qs.count()
group4_total = sum(group4_qs.values_list('diff', flat=True)) or 0
group4_sample = group4_qs[:20]
# =========================================================================
# نمودارها و آمار تکمیلی داشبورد
# =========================================================================
debt_future = sarresid_qs.filter(payStatus="false", sarresidDT__gt=anchor_dt).aggregate(Sum('price'))['price__sum'] or 0
dt_30_days_ago = anchor_dt - datetime.timedelta(days=30)
debt_0_30 = sarresid_qs.filter(payStatus="false", sarresidDT__lte=anchor_dt, sarresidDT__gte=dt_30_days_ago).aggregate(Sum('price'))['price__sum'] or 0
dt_90_days_ago = anchor_dt - datetime.timedelta(days=90)
debt_30_90 = sarresid_qs.filter(payStatus="false", sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago).aggregate(Sum('price'))['price__sum'] or 0
debt_above_90 = sarresid_qs.filter(payStatus="false", sarresidDT__lt=dt_90_days_ago).aggregate(Sum('price'))['price__sum'] or 0
payment_articles = article_qs.filter(credit__gt=0).values('nahvePardakht').annotate(total_amount=Sum('credit')).order_by('-total_amount')
pay_labels = []
pay_data = []
for item in payment_articles:
label = item['nahvePardakht'] or 'سایر/نامشخص'
pay_labels.append(label)
pay_data.append(item['total_amount'])
store_leaks = store_qs.filter(
movement_type__in=[ProductInStore.MovementType.WARRANTY, ProductInStore.MovementType.PROMOTION]
).values('movement_type').annotate(total_qty=Sum('s_out'))
leak_data = {'WARRANTY': 0, 'PROMOTION': 0}
for leak in store_leaks:
leak_data[leak['movement_type']] = leak['total_qty']
context = {
'kpi_total_unpaid': total_unpaid_debts,
'kpi_total_sales': total_sales_period,
'kpi_overdue_checks': overdue_checks_count,
# آمار کلان ریاضیاتی
'global_mande': global_mande,
'g_sar': g_sar,
'global_diff': global_diff,
# متغیرهای ۴ گروه حسابرسی
'group1_count': group1_count, 'group1_total': group1_total, 'group1_sample': group1_sample,
'group2_count': group2_count, 'group2_total': group2_total, 'group2_sample': group2_sample,
'group3_count': group3_count, 'group3_total': group3_total, 'group3_sample': group3_sample,
'group4_count': group4_count, 'group4_total': group4_total, 'group4_sample': group4_sample,
'trend_labels': trend_labels,
'trend_data': trend_data,
'aging_data': [debt_future, debt_0_30, debt_30_90, debt_above_90],
'pay_labels': pay_labels,
'pay_data': pay_data,
'warranty_qty': leak_data['WARRANTY'],
'promotion_qty': leak_data['PROMOTION'],
'filters': {
'azTarikh': az_tarikh,
'taTarikh': ta_tarikh,
}
}
return render(request, 'admin_panel/financial_dashboard.html', context)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def bedehi(request):
try:
debit = Article.objects.filter().aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter().aggregate(Sum('credit'))['credit__sum']
mandehesabCs = debit - credit
articls = Article.objects.filter().order_by('-createdDT')
articlsPaginator = Paginator(articls, 10)
pageNumber = request.GET.get("page", 1)
articl_objs = articlsPaginator.get_page(pageNumber)
except:
articls =None
mandehesabCs = 0
data = {'articls': articl_objs, 'mandehesabKP': mandehesabCs}
return render(request, 'admin_panel/bedehi.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def hesabdari(request):
page_name = 'sarresidShodeha'
anchor_dt = datetime.datetime.now()
# تعریف محدوده‌های زمانی ماتریس ریسک (سن مطالبات)
dt_30_days_ago = anchor_dt - datetime.timedelta(days=30)
dt_90_days_ago = anchor_dt - datetime.timedelta(days=90)
# کوئری پایه: تمام سررسیدهای پرداخت‌نشده که از موعد آن‌ها گذشته است
base_qs = Sarresid.objects.filter(payStatus="false", sarresidDT__lte=anchor_dt)
# محاسبه پرسرعت مجموع مبالغ در هر دسته (توسط موتور دیتابیس)
matrix_totals = base_qs.aggregate(
minor_delay=Sum('price', filter=Q(sarresidDT__gte=dt_30_days_ago)),
high_risk=Sum('price', filter=Q(sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago)),
critical=Sum('price', filter=Q(sarresidDT__lt=dt_90_days_ago)),
total_sum=Sum('price')
)
# دریافت فیلتر انتخابی کاربر از URL
risk_filter = request.GET.get('risk_level', 'all')
# اعمال فیلتر روی لیست نمایش داده شده
if risk_filter == 'minor':
filtered_qs = base_qs.filter(sarresidDT__gte=dt_30_days_ago)
elif risk_filter == 'high':
filtered_qs = base_qs.filter(sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago)
elif risk_filter == 'critical':
filtered_qs = base_qs.filter(sarresidDT__lt=dt_90_days_ago)
else:
filtered_qs = base_qs
# مرتب‌سازی: قدیمی‌ترین‌ها (خطرناک‌ترین‌ها) در صدر جدول قرار می‌گیرند
filtered_qs = filtered_qs.order_by('-sarresidDT')
page_number = request.GET.get('page', 1)
paginator = Paginator(filtered_qs, per_page=50)
all_sarresid_pgi = paginator.get_page(page_number)
data = {
'page_name': page_name,
'dateNow': anchor_dt.strftime('%Y-%m-%d %H:%M:%S'),
'all_sarresid': all_sarresid_pgi,
'current_filter': risk_filter,
# متغیرهای ماتریس ریسک
'sum_sarresid': matrix_totals['total_sum'] or 0,
'minor_sum': matrix_totals['minor_delay'] or 0,
'high_sum': matrix_totals['high_risk'] or 0,
'critical_sum': matrix_totals['critical'] or 0,
}
return render(request, 'admin_panel/hesabdari.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def hesabdari_checkPass(request):
page_name = 'checkPass'
dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
all_sarresid = Sarresid.objects.filter(payStatus="true", type='check').order_by('-sarresidDT')
page_number = request.GET.get('page', None)
paginator = Paginator(all_sarresid, per_page=50)
all_sarresid_pgi = paginator.get_page(page_number)
#paginator = Paginator(all_sarresid, per_page=200)
data = {'all_sarresid': all_sarresid_pgi, "dateNow":dateNow, "page_name":page_name}
return render(request, 'admin_panel/hesabdari.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def hesabdari_checkPassFail(request):
page_name = 'checkPassFaild'
dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
all_sarresid = Sarresid.objects.filter(payStatus="false", type='check').order_by('sarresidDT')
count_check_passfail = all_sarresid.count()
sum_check_passfail = all_sarresid.aggregate(Sum('price'))['price__sum']
page_number = request.GET.get('page', None)
paginator = Paginator(all_sarresid, per_page=50)
all_sarresid_pgi = paginator.get_page(page_number)
#paginator = Paginator(all_sarresid, per_page=200)
data = {'all_sarresid': all_sarresid_pgi, 'sum_check_passfail':sum_check_passfail, 'count_check_passfail': count_check_passfail, "dateNow":dateNow, "page_name":page_name}
return render(request, 'admin_panel/hesabdari.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def hesabdari_residnashone(request):
page_name = 'sarresidNaShodeha'
dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
all_sarresid = Sarresid.objects.filter(payStatus="false", sarresidDT__gte=dateNow).order_by('sarresidDT')
sarresidNashode = all_sarresid.aggregate(Sum('price'))['price__sum']
page_number = request.GET.get('page', None)
paginator = Paginator(all_sarresid, per_page=50)
all_sarresid_pgi = paginator.get_page(page_number)
#paginator = Paginator(all_sarresid, per_page=200)
data = {'sarresidNashode': sarresidNashode,'all_sarresid': all_sarresid_pgi, "dateNow":dateNow, "page_name":page_name}
return render(request, 'admin_panel/hesabdari.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def bedehiKhadamat(request):
#bedehiKhadamat
try:
debit = Article.objects.filter(typePy='khadamat').aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter(typePy='khadamat').aggregate(Sum('credit'))['credit__sum']
mandehesabCs = debit - credit
articls = Article.objects.filter(typePy='khadamat').order_by('-createdDT')[:100]
except:
articls =None
mandehesabCs = 0
data = {'articls': articls, 'mandehesabKP': mandehesabCs}
return render(request, 'admin_panel/bedehi.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def bedehiNasb(request):
try:
debit = Article.objects.filter(typePy='nasb').aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter(typePy='nasb').aggregate(Sum('credit'))['credit__sum']
mandehesabCs = debit - credit
articls = Article.objects.filter(typePy='nasb').order_by('-createdDT')[:100]
except:
articls =None
mandehesabCs = 0
data = {'articls': articls, 'mandehesabKP': mandehesabCs}
return render(request, 'admin_panel/bedehi.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def passCheck(request, articl_id):
sarresidINC = Sarresid.objects.get(id=articl_id)
user_op = request.user
desc = request.POST.get('message')
articleHesabDari2 = Article()
articleHesabDari2.debit = 0
articleHesabDari2.credit = sarresidINC.price
articleHesabDari2.customer = sarresidINC.customer
articleHesabDari2.factor = sarresidINC.factor
articleHesabDari2.desc = 'وصول چک' + " " + str(sarresidINC.customer.name_family)
if sarresidINC.factor:
articleHesabDari2.desc += " فاکتور شماره: " + str(sarresidINC.factor.id)
articleHesabDari2.nahvePardakht = 'وصول چک'
articleHesabDari2.payStatus = "true"
articleHesabDari2.user = user_op
articleHesabDari2.save()
sarresidINC.payStatus = "true"
sarresidINC.desc = desc
sarresidINC.save()
return HttpResponse("ok")
#return redirect(request.META.get('HTTP_REFERER'))
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
@require_POST
def update_check_info(request, sarresid_id):
# دریافت رکورد مورد نظر
sarresid = get_object_or_404(Sarresid, id=sarresid_id)
# اطمینان از اینکه آیتم حتما چک است
if sarresid.type != 'check':
messages.error(request, "عملیات نامعتبر: این سند چک نیست.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# دریافت مقادیر فرم با حذف فاصله‌های اضافی
check_row_number = request.POST.get('check_row_number', '').strip()
check_sayyad_id = request.POST.get('check_sayyad_id', '').strip()
check_owner_name = request.POST.get('check_owner_name', '').strip()
check_owner_phone = request.POST.get('check_owner_phone', '').strip()
# اعتبارسنجی فیلدهای اجباری
if not check_row_number or not check_sayyad_id or not check_owner_name:
messages.error(request, "شماره ردیف، شناسه صیادی و نام مالک اجباری است.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# اعتبارسنجی شناسه صیادی (باید دقیقا ۱۶ رقم عدد باشد)
if not check_sayyad_id.isdigit() or len(check_sayyad_id) != 16:
messages.error(request, "شناسه صیادی باید دقیقاً ۱۶ رقم عدد باشد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# اعتبارسنجی شماره تلفن (در صورت وارد شدن باید فقط عدد باشد)
if check_owner_phone and not check_owner_phone.isdigit():
messages.error(request, "شماره تلفن مالک چک باید فقط شامل اعداد باشد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
try:
# بروزرسانی اطلاعات
sarresid.check_row_number = check_row_number
sarresid.check_sayyad_id = check_sayyad_id
sarresid.check_owner_name = check_owner_name
sarresid.check_owner_phone = check_owner_phone
# آپدیت فیلد توضیحات با اطلاعات جدید
sarresid.desc = f"صیادی: {check_sayyad_id} | مالک: {check_owner_name} | تلفن: {check_owner_phone or 'ندارد'}"
sarresid.save()
messages.success(request, "اطلاعات چک با موفقیت ثبت/بروزرسانی شد.")
except Exception as e:
messages.error(request, "خطای سیستمی در بروزرسانی اطلاعات رخ داد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def addSarresid(request, cs_id):
if request.method != 'POST':
return HttpResponseBadRequest("متد ارسالی نامعتبر است.")
try:
customerObj = get_object_or_404(Customer, id=cs_id)
# دریافت فیلدهای اصلی
sarrresidUNIX = request.POST.get('n_sarresidDateTime')
sarresidPrice = request.POST.get('n_sarresidPrice')
sarresidType = request.POST.get('n_sarresidType')
# دریافت و پاک‌سازی فیلدهای چک (جلوگیری از باگ فاصله‌های خالی)
check_row_number = request.POST.get('check_row_number', '').strip()
check_sayyad_id = request.POST.get('check_sayyad_id', '').strip()
check_owner_name = request.POST.get('check_owner_name', '').strip()
check_owner_phone = request.POST.get('check_owner_phone', '').strip()
# --- لایه اول: اعتبارسنجی فیلدهای پایه ---
if not sarrresidUNIX or not sarresidPrice or not sarresidType:
messages.error(request, "لطفا فیلدهای اصلی (مبلغ، نوع و تاریخ) را پر کنید.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# --- لایه دوم: اعتبارسنجی دقیق و امنیتی فیلدهای چک ---
if sarresidType == "check":
if not check_row_number or not check_sayyad_id or not check_owner_name:
messages.error(request, "برای ثبت چک، وارد کردن شماره ردیف، شناسه صیادی و نام مالک اجباری است.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# بررسی ۱۶ رقمی و عددی بودن شناسه صیادی
if not check_sayyad_id.isdigit() or len(check_sayyad_id) != 16:
messages.error(request, "شناسه صیادی باید دقیقاً ۱۶ رقم عدد باشد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# بررسی عددی بودن شماره تلفن (در صورت وارد شدن)
if check_owner_phone and not check_owner_phone.isdigit():
messages.error(request, "شماره تلفن مالک چک باید فقط شامل اعداد باشد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# تبدیل نوع داده‌ها با مدیریت خطا (جلوگیری از کرش سرور 500)
try:
sarResidDateTime01 = datetime.datetime.fromtimestamp(int(sarrresidUNIX) / 1000.0)
price_int = int(sarresidPrice.replace(',', ''))
except ValueError:
messages.error(request, "مقادیر تاریخ یا مبلغ نامعتبر است.")
return redirect(request.META.get('HTTP_REFERER', '/'))
# --- لایه سوم: ذخیره‌سازی اتمیک (تضمین جامعیت داده‌ها) ---
with transaction.atomic():
# ۱. ثبت سند حسابداری
articleHesabDariObj = Article()
articleHesabDariObj.debit = price_int
articleHesabDariObj.credit = 0
articleHesabDariObj.customer = customerObj
articleHesabDariObj.user = request.user
if sarresidType == "check":
articleHesabDariObj.desc = f"بدهی بابت ثبت چک دستی (صیادی: {check_sayyad_id})"
elif sarresidType == "nesiye":
articleHesabDariObj.desc = "بدهی بابت ثبت نسیه دستی"
articleHesabDariObj.save()
# ۲. ثبت سررسید / چک
sarresidINC = Sarresid()
sarresidINC.customer = customerObj
sarresidINC.price = price_int
sarresidINC.payStatus = "false"
sarresidINC.type = sarresidType
sarresidINC.user = request.user
sarresidINC.sarresidDT = sarResidDateTime01
if sarresidType == "check":
sarresidINC.check_row_number = check_row_number
sarresidINC.check_sayyad_id = check_sayyad_id
sarresidINC.check_owner_name = check_owner_name
sarresidINC.check_owner_phone = check_owner_phone
sarresidINC.desc = f"صیادی: {check_sayyad_id} | مالک: {check_owner_name} | تلفن: {check_owner_phone or 'ندارد'}"
sarresidINC.save()
messages.success(request, "اطلاعات با موفقیت ثبت شد.")
return redirect(request.META.get('HTTP_REFERER', '/'))
except Exception as e:
logger.error(f"Error in addSarresid: {str(e)}", exc_info=True)
messages.error(request, "خطای سیستمی رخ داد. لطفا مجددا تلاش کنید.")
return redirect(request.META.get('HTTP_REFERER', '/'))
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def pardakhtNesiye(request, cs_id):
try:
customerObj = Customer.objects.get(id=cs_id)
user_op = request.user
sarrresidUNIX = request.POST.get('next_sarresidDateTime', None)
sarresidPrice = int(request.POST.get('next_sarresidPrice', None))
sr_articlID = request.POST.get('sr_articlID', None)
nahvePardakht = request.POST.get('nahvePardakht', None)
desc_sar = request.POST.get('desc_sar', None)
sarResidDateTime01 = datetime.datetime.fromtimestamp(int(sarrresidUNIX)/1000.0)
SarresidObj = Sarresid.objects.get(id=sr_articlID)
#return HttpResponse(sarresidPrice)
debitNow = SarresidObj.price - sarresidPrice
#return HttpResponse(debitNow)
if debitNow==0:
#توسیه بدهی بخورد
SarresidObj.price = 0
SarresidObj.payStatus = "true"
elif debitNow >= 1:
SarresidObj.price = debitNow
SarresidObj.sarresidDT = sarResidDateTime01
else:
return HttpResponse("خطا در مورد مبلغ")
SarresidObj.save()
if str(nahvePardakht) == 'naghd':
nahvePardakht = 'نقد'
elif str(nahvePardakht) == 'kartbekart':
nahvePardakht = 'کارت به کارت'
elif str(nahvePardakht) == 'kartkhan':
nahvePardakht = 'کارت خوان'
elif str(nahvePardakht) == 'takhfif':
nahvePardakht = 'تخفیف'
elif str(nahvePardakht) == 'check':
nahvePardakht = 'چک'
articleHesabDariObj = Article()
articleHesabDariObj.debit = 0
articleHesabDariObj.credit = int(sarresidPrice)
if desc_sar:
articleHesabDariObj.desc = "پرداختی بابت نسیه" + " | " + desc_sar
else:
articleHesabDariObj.desc = "پرداختی بابت نسیه"
articleHesabDariObj.customer = customerObj
articleHesabDariObj.nahvePardakht = nahvePardakht
articleHesabDariObj.user = user_op
articleHesabDariObj.save()
return redirect(request.META.get('HTTP_REFERER'))
except:
return HttpResponse("خطا در ثبت ")
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def rezayat_dashboard(request):
# دریافت تمامی نظرسنجی‌های ثبت شده معتبر (که تاریخ نظرسنجی دارند)
valid_surveys = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15, 20]
)
# ۱. محاسبات شاخص‌های کلیدی (KPIs)
total_surveys = valid_surveys.count()
avg_grade = valid_surveys.aggregate(Avg('grade'))['grade__avg'] or 0
# ۲. استخراج داده‌ها برای نمودار توزیع رضایت
grade_distribution = valid_surveys.values('grade').annotate(count=Count('id'))
chart_dict = {20: 0, 15: 0, 10: 0, 5: 0}
for item in grade_distribution:
g = item['grade']
if g in chart_dict:
chart_dict[g] = item['count']
chart_labels = ['عالی (۲۰)', 'متوسط (۱۵)', 'ضعیف (۱۰)', 'بد (۵)']
chart_data_list = [chart_dict[20], chart_dict[15], chart_dict[10], chart_dict[5]]
# ۳. تبدیل امن آرایه‌ها به JSON برای Chart.js
chart_labels_json = json.dumps(chart_labels, ensure_ascii=False)
chart_data_json = json.dumps(chart_data_list)
has_chart_data = sum(chart_data_list) > 0
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
low_score_actions = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15] # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
).order_by('-nazarDT')
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
context = {
'total_surveys': total_surveys,
'avg_grade': round(avg_grade, 1),
'chart_labels_json': chart_labels_json,
'chart_data_json': chart_data_json,
'has_chart_data': has_chart_data,
'low_score_actions': low_score_actions,
# تعداد کل موارد نیازمند پیگیری (متوسط + ضعیف + بد)
'unhappy_count': chart_dict[15] + chart_dict[10] + chart_dict[5],
'allUsers': allUsers,
}
return render(request, 'admin_panel/report_rezayat.html', context)
+472
View File
@@ -0,0 +1,472 @@
"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``)."""
import re
from datetime import datetime, time
from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from ..models import (Customer, SMSDedup, SMSProviderConfig, SMSQueue,
SMSTemplate, SMSType)
from ..sms.enqueue import enqueue_sms
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
def _notice(url, code, **extra):
"""بازگشت به ``url`` همراه با کد پیام نتیجه (جایگزین flash message).
کد در querystring می‌رود و فقط در همان صفحه رندر می‌شود؛ بر خلاف پیام جنگو
که در session می‌ماند و می‌تواند در صفحه‌ای بی‌ربط ظاهر شود.
"""
query = urlencode({"notice": code, **extra})
return redirect(f"{url}{'&' if '?' in url else '?'}{query}")
def _customer_url(c_id):
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
# متغیرهای قابل استفاده در متن قالب‌ها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
("{amount}", "مبلغ"),
("{amount_sep}", "مبلغ با جداکنندهٔ هزارگان"),
("{date}", "تاریخ جلالی"),
("{days}", "تعداد روز"),
("{check_row}", "شمارهٔ ردیف چک"),
("{owner_name}", "نام مالک چک"),
]
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بیدرنگ پس از ثبت مشتری"},
SMSType.CHEQUE_DUE: {"icon": "🧾", "when": "پیش از سررسید چک"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
SMSType.CHEQUE_REGISTRATION: {"icon": "⏳", "when": "تا ثبت‌شدن اطلاعات چک"},
SMSType.DEBT_DUE: {"icon": "💰", "when": "روز سررسید وصول مطالبات"},
SMSType.SURVEY: {"icon": "⭐", "when": "پس از صدور فاکتور"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید"},
SMSType.CUSTOM: {"icon": "✍️", "when": "ارسال دستی از صفحهٔ مشتری"},
}
# نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود).
SAMPLE_CONTEXT = {
"name": "مشتری نمونه",
"amount": 250000,
"amount_sep": "250,000",
"date": None, # در زمان ساخت ویو با تاریخ امروز پر می‌شود
"days": 2,
"check_row": "1234",
"owner_name": "نام مالک",
}
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_settings(request):
config = SMSProviderConfig.load()
if request.method == "POST":
if request.POST.get("action") == "test":
phone = request.POST.get("test_phone", "").strip()
# متن آزمایشی از فرم همان کارت خوانده و برای دفعهٔ بعد ذخیره می‌شود.
test_text = (request.POST.get("test_message") or "").strip()
if test_text:
config.test_message = test_text
config.save(update_fields=["test_message"])
enqueue_sms(
customer=None, sms_type=SMSType.CUSTOM,
message=test_text or config.test_message,
recipients=resolve_recipients_test(phone), created_by=request.user,
approved=True, # ارسال دستیِ مدیر؛ نیاز به بازبینی صف ندارد.
)
return _notice(reverse("admin_panel:sms_settings"), "test_sent")
config.provider = request.POST.get("provider", config.provider)
config.is_enabled = request.POST.get("is_enabled") == "on"
config.api_url = request.POST.get("api_url", "").strip()
config.webservice_url = request.POST.get("webservice_url", "").strip()
config.panel_no = request.POST.get("panel_no", "").strip()
config.username = request.POST.get("username", "").strip()
config.sender = request.POST.get("sender", "").strip()
config.api_token = request.POST.get("api_token", "").strip()
try:
config.timeout_seconds = int(request.POST.get("timeout_seconds") or 30)
except (TypeError, ValueError):
config.timeout_seconds = 30
new_password = request.POST.get("password", "")
if new_password:
config.password = new_password
config.allow_backlog = request.POST.get("allow_backlog") == "on"
for field in ("backlog_max", "max_per_run", "send_interval_seconds"):
raw = request.POST.get(field)
if raw is not None and str(raw).strip() != "":
try:
setattr(config, field, max(0, int(raw)))
except (TypeError, ValueError):
pass
scan_since_raw = (request.POST.get("scan_since") or "").strip()
if scan_since_raw:
# اول ISO/گرگوری، بعد تاریخ شمسی (YYYY/MM/DD). نامعتبر = مقدار قبلی حفظ می‌شود.
parsed = parse_scan_since(scan_since_raw, None)
if parsed is None:
parsed = parse_jalali_date(scan_since_raw)
if parsed is not None:
config.scan_since = parsed
elif config.scan_since is None:
config.scan_since = timezone.now()
config.save()
return _notice(reverse("admin_panel:sms_settings"), "settings_saved")
data = {
"config": config,
"provider_choices": SMSProviderConfig.ProviderChoices,
"scan_since_jalali": format_jalali(config.scan_since),
}
return render(request, "admin_panel/sms_settings.html", data)
def resolve_recipients_test(phone):
from integrations.sms.normalize import normalize_mobile
normalized = normalize_mobile(phone)
return [normalized] if normalized else []
def parse_scan_since(raw, fallback):
"""تجزیهٔ «خط شروع» با قالب‌های رایج؛ در صورت نامعتبر بودن، مقدار قبلی حفظ می‌شود."""
from django.utils.dateparse import parse_datetime
value = parse_datetime(raw)
if value is None:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
value = datetime.strptime(raw, fmt)
break
except (TypeError, ValueError):
continue
if value is None:
return fallback
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return value
def parse_jalali_date(raw):
"""«YYYY/MM/DD» شمسی → datetime آگاه در آغاز همان روز (تایم‌زون پروژه).
جداکنندهٔ اسلش مخصوص شمسی است تا با قالب گرگوری `YYYY-MM-DD` اشتباه نشود.
خروجی «آغاز روز» است چون «تاریخ شروع دوره» در سطح روز معنا دارد.
"""
from persiantools.jdatetime import JalaliDate
match = re.fullmatch(r"(\d{4})/(\d{1,2})/(\d{1,2})", (raw or "").strip())
if not match:
return None
try:
gregorian = JalaliDate(*(int(part) for part in match.groups())).to_gregorian()
except (TypeError, ValueError):
return None
naive = datetime.combine(gregorian, time.min)
return timezone.make_aware(naive, timezone.get_current_timezone())
def _sample_context():
"""نمونهٔ داده برای پیش‌نمایش: یک نسخه با مقادیر واقعی (رندر سمت سرور) و یک نسخهٔ رشته‌ای."""
today = timezone.localdate()
raw = dict(SAMPLE_CONTEXT, date=today)
as_text = {
key: (format_jalali(today) if key == "date" else str(value))
for key, value in raw.items()
}
return raw, as_text
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_templates(request):
raw_sample, text_sample = _sample_context()
rows = []
for template in SMSTemplate.objects.all().order_by("sms_type"):
ui = SMS_TYPE_UI.get(template.sms_type, {})
info = timing_for(template.sms_type)
rows.append({
"template": template,
"code": template.sms_type,
"label": template.get_sms_type_display(),
"icon": ui.get("icon", "✉️"),
"when": ui.get("when", ""),
"timing": info,
"timing_value": current_timing_value(template),
"timing_label": info.get("label") or "",
# پیش‌نمایش اولیه سمت سرور (برای زمانی که جاوااسکریپت اجرا نشود).
"preview": render_template(template.text, raw_sample),
})
data = {
"rows": rows,
"variables": TEMPLATE_VARIABLES,
# پیکربندی جاوااسکریپت صفحه (نمونهٔ مقادیر و فهرست متغیرهای مجاز).
# به‌صورت dict پاس داده می‌شود تا خود ``json_script`` آن را سریال‌سازی کند.
"smsx_config": {
"sample": text_sample,
"tokens": [token for token, _desc in TEMPLATE_VARIABLES],
},
}
return render(request, "admin_panel/sms_templates.html", data)
def _templates_redirect(anchor="", notice="", **extra):
"""بازگشت به صفحهٔ قالب‌ها با باز شدن کارتِ همان نوع پیامک.
کد پیام نتیجه پیش از ``#`` (fragment) در querystring قرار می‌گیرد.
"""
url = reverse("admin_panel:sms_templates")
if notice:
url = f"{url}?{urlencode({'notice': notice, **extra})}"
return redirect(f"{url}#type-{anchor}" if anchor else url)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_template_edit(request, t_id):
"""ویرایش یک قالب. فیلد زمانی بر اساس نوع پیامک انتخاب و اعتبارسنجی می‌شود.
فرم فقط فیلد زمانیِ مربوط به همان نوع را می‌فرستد؛ ``apply_timing`` تضمین می‌کند
بقیهٔ فیلدهای زمانی صفر/خالی بمانند تا داده هرگز ناهمخوان نشود.
"""
template = get_object_or_404(SMSTemplate, id=t_id)
if request.method == "POST":
template.text = request.POST.get("text", template.text)
template.title = request.POST.get("title", template.title).strip() or template.title
template.is_enabled = request.POST.get("is_enabled") == "on"
template.auto_send = request.POST.get("auto_send") == "on"
info = timing_for(template.sms_type)
value = None
if info["field"] is not None:
raw = (request.POST.get("timing_value") or "").strip()
if raw:
try:
value = max(0, int(raw))
except (TypeError, ValueError):
return _templates_redirect(
template.sms_type, "tpl_bad_timing",
)
apply_timing(template, value)
template.save()
return _templates_redirect(
template.sms_type, "tpl_saved", title=template.title,
)
return _templates_redirect(template.sms_type)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_customer_toggle(request, c_id):
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
sms_type = request.POST.get("sms_type", "")
valid = {code for code, _ in SMSType.choices}
if sms_type in valid:
enabled = request.POST.get("is_enabled") == "true"
setting, _created = customer.sms_settings.get_or_create(
sms_type=sms_type, defaults={"is_enabled": enabled},
)
setting.is_enabled = enabled
setting.save()
return _notice(_customer_url(c_id), "customer_sms_saved")
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_primary_toggle(request, c_id):
"""تعیین/حذف یک شماره به‌عنوان شمارهٔ «اصلی» (گیرنده پیامک).
انتخاب بر اساس **خودِ شماره** (نرمال‌شده) ذخیره می‌شود، نه کلید رکورد؛ چون رکوردهای
شماره در ویرایش مشتری حذف/بازسازی می‌شوند و کلیدشان پایدار نیست. شماره‌های ورودی
اعتبارسنجی می‌شوند تا مقدار بی‌معنی به‌عنوان گیرنده ذخیره نشود.
"""
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
wanted = (request.POST.get("phone") or "").strip()
enabled = request.POST.get("is_primary") == "true"
normalized = next(
(e.normalized for e in customer_phone_entries(customer)
if e.raw == wanted or e.display == wanted or e.normalized == wanted),
"",
)
if not normalized:
return _notice(_customer_url(c_id), "primary_not_found")
current = [n for n in stored_primary_normalized(customer) if n != normalized]
if enabled:
current.append(normalized)
save_primary_normalized(customer, current)
return _notice(_customer_url(c_id), "primary_updated")
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_send_custom(request, c_id):
"""ارسال پیامک با متن سفارشی برای همین مشتری.
اگر کادر متن خالی بماند، متن قالب «پیام سفارشی» استفاده می‌شود. متغیرهایی مثل
``{name}`` در متن واردشده هم رندر می‌شوند.
"""
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
text = (request.POST.get("text", "") or "").strip()
if not text:
template = SMSTemplate.objects.filter(
sms_type=SMSType.CUSTOM, is_enabled=True,
).first()
text = (template.text if template else "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
return _notice(_customer_url(c_id), "custom_empty")
created = enqueue_sms(
customer=customer, sms_type=SMSType.CUSTOM, message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
return _notice(_customer_url(c_id), code)
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue(request):
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = SMSQueue.objects.all().select_related("customer").order_by("-created_at")
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 50)
page = paginator.get_page(request.GET.get("page"))
# آمار روی کل صف (بدون اعمال فیلترها) محاسبه می‌شود تا تصویر کلی همیشه دیده شود.
# order_by() لازم است وگرنه ترتیب پیش‌فرض مدل (created_at-) به GROUP BY اضافه
# می‌شود و هر ردیف گروه جدا می‌شود.
counts = dict(
SMSQueue.objects.order_by().values_list("status").annotate(n=Count("id"))
)
data = {
"queue": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"pending_count": counts.get(SMSQueue.Status.PENDING, 0),
"total_count": sum(counts.values()),
"filtered_count": paginator.count,
# (کد وضعیت، برچسب، تعداد) — به ترتیب تعریف Status.
"status_stats": [
(code, label, counts.get(code, 0))
for code, label in SMSQueue.Status.choices
],
}
return render(request, "admin_panel/sms_queue.html", data)
def _approve(queryset):
"""پیامک‌های «در انتظار تأیید» را تأیید و آمادهٔ ارسال می‌کند."""
return queryset.filter(status=SMSQueue.Status.PENDING).update(
status=SMSQueue.Status.APPROVED, next_retry_at=None, updated_at=timezone.now(),
)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_approve(request, q_id):
"""تأیید یک پیامک برای ارسال توسط worker."""
get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
code = "approved" if _approve(SMSQueue.objects.filter(id=q_id)) else "not_pending"
return _notice(reverse("admin_panel:sms_queue"), code)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_delete(request, q_id):
"""حذف یک پیامک از صف (مثلاً پس از بازبینی و رد آن)."""
item = get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_bulk(request):
"""اقدام گروهی روی صف: تأیید/حذف موارد انتخاب‌شده یا تأیید کل موارد در انتظار."""
if request.method == "POST":
action = request.POST.get("action", "")
if action == "approve_all":
count = _approve(SMSQueue.objects.all())
code, extra = "bulk_approved_all", {"count": count}
else:
ids = request.POST.getlist("ids")
selected = SMSQueue.objects.filter(id__in=ids)
if action == "approve_selected":
count = _approve(selected)
code, extra = "bulk_approved", {"count": count}
elif action == "delete_selected":
count = selected.count()
selected.delete()
code, extra = "bulk_deleted", {"count": count}
else:
code, extra = "invalid_action", {}
return _notice(reverse("admin_panel:sms_queue"), code, **extra)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
"""خالی کردن کل صف — هر وضعیتی، بدون بازگشت.
پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود تا اسکنر
در اجرای بعدی همان پیامک‌ها را از نو نسازد. رکوردهایی که کلید ندارند (مثلاً
پیامک دستی) کلید مشتق از خودشان را می‌گیرند.
"""
if request.method == "POST":
rows = SMSQueue.objects.all()
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
SMSDedup.objects.bulk_create(
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,
)
count = rows.count()
rows.delete()
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_retry(request, q_id):
item = get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
item.status = SMSQueue.Status.APPROVED
item.next_retry_at = timezone.now()
item.last_error = ""
# صفر کردن تلاش‌ها تا پیامکِ failed دوباره فرصت ارسال بگیرد.
item.attempts = 0
item.save(update_fields=["status", "next_retry_at", "last_error", "attempts", "updated_at"])
return _notice(reverse("admin_panel:sms_queue"), "retried")
return redirect("admin_panel:sms_queue")
+458
View File
@@ -0,0 +1,458 @@
from django.db import transaction, connection
from django.db.models import Sum, Q
from django.shortcuts import redirect, render, get_object_or_404
from django.http import HttpResponse, HttpResponseBadRequest
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
from django.core.exceptions import PermissionDenied, ValidationError
from django.views.decorators.http import require_POST
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
from ..models import StoreRoom, ProductCategory, Product, ProductInStore, StoreTransferRequest
from ..services import (create_transfer_request, approve_transfer_request,
reject_transfer_request, cancel_transfer_request)
from ._helpers import logger, _safe_int
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def stores(request):
if request.user.username not in ('admin', 'office1', 'office2', 'office3', 'khatibi'):
all_store = StoreRoom.objects.filter(user__id=request.user.id).order_by('id')[:20]
else:
all_store = StoreRoom.objects.all().order_by('id')[:20]
data = {'all_store': all_store}
return render(request, 'admin_panel/stores.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def store(request, s_id):
productCategory = ProductCategory.objects.all()
storeO = get_object_or_404(StoreRoom, id=s_id)
searchStatus = False
searchQS = request.POST.get('searchQS', '').strip()
mojodi = request.POST.get('mojodi', '')
# ۱. محاسبه موجودی فیزیکی کل کالاها با یک کوئری تک‌ضرب و سریع GROUP BY
stock_balances = ProductInStore.objects.filter(
StoreRoom=storeO, year=1400
).values('Product_id').annotate(
total=Sum('s_in') - Sum('s_out')
).order_by()
stock_dict = {item['Product_id']: item['total'] for item in stock_balances}
# ۲. محاسبه کل کالاهای در انتظار خروج
pending_out_balances = StoreTransferRequest.objects.filter(
source_store=storeO, status=StoreTransferRequest.Status.PENDING
).values('product_id').annotate(
total=Sum('quantity')
).order_by()
pending_out_dict = {item['product_id']: item['total'] for item in pending_out_balances}
# ۳. محاسبه کل کالاهای در انتظار ورود
pending_in_balances = StoreTransferRequest.objects.filter(
target_store=storeO, status=StoreTransferRequest.Status.PENDING
).values('product_id').annotate(
total=Sum('quantity')
).order_by()
pending_in_dict = {item['product_id']: item['total'] for item in pending_in_balances}
# ۴. استخراج سریع تمام آیدی‌های منحصربه‌فرد مرتبط با این انبار در سطح حافظه
all_pids = set(stock_dict.keys()) | set(pending_out_dict.keys()) | set(pending_in_dict.keys())
# ۵. کوئری نهایی فوق‌بهینه برای خواندن کالاها (حل باگ N+1 با دستور select_related)
products_qs = Product.objects.select_related('ProductCategory').filter(id__in=all_pids)
# اعمال فیلتر متنی جستجو در سطح دیتابیس
if searchQS:
products_qs = products_qs.filter(product_name__icontains=searchQS)
searchStatus = True
# 🛠️ اصلاح فرآیند ترتیب: استفاده از 'id' جهت انطباق ۱۰۰٪ با ترتیب پیش‌فرض و سنتی دیتابیس شما
# نکته: اگر مایلید بر اساس زمان ایجاد مرتب شود، می‌توانید آن را به 'createdDT' یا '-createdDT' تغییر دهید.
products_list = list(products_qs.order_by('id'))
# ۶. معماری زمان ثابت O(1): چسباندن پرسرعت دیتاها در حافظه رم سرور
for prd in products_list:
prd.mojodi = stock_dict.get(prd.id, 0)
prd.pending_out = pending_out_dict.get(prd.id, 0)
prd.pending_in = pending_in_dict.get(prd.id, 0)
# ۷. اعمال فیلترهای منطقی موجودی در لایه پایتون
if mojodi == "0":
products_list = [p for p in products_list if p.mojodi <= 0]
searchStatus = True
elif mojodi == "1":
products_list = [p for p in products_list if p.mojodi > 0]
searchStatus = True
data = {
'storeO': storeO,
'productSum': products_list,
's_id': s_id,
'searchStatus': searchStatus,
"mojodi": mojodi,
'djsearchQS': searchQS,
'productCategory': productCategory
}
return render(request, 'admin_panel/store.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operations(request, s_id):
page = request.GET.get('page', 1)
productCategory = ProductCategory.objects.all()
storeO = get_object_or_404(StoreRoom, id=s_id)
# بهینه‌سازی کلیدی: استفاده از select_related برای جلوگیری از کندی و پر شدن RAM دیتابیس
operationsList = ProductInStore.objects.filter(
StoreRoom=storeO, year=1400
).select_related('Product').order_by('-createdDT')
paginator = Paginator(operationsList, 25)
try:
data_list = paginator.page(page)
except PageNotAnInteger:
data_list = paginator.page(1)
except EmptyPage:
data_list = paginator.page(paginator.num_pages)
data = {
'storeO': storeO,
'operationsList': data_list,
's_id': s_id,
'productCategory': productCategory
}
return render(request, 'admin_panel/operations.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operationProduct(request, s_id, p_id):
productCategory = ProductCategory.objects.all()
storeO = get_object_or_404(StoreRoom, id=s_id)
# بهینه‌سازی کلیدی در فیلتر اختصاصی محصول
operationsList = ProductInStore.objects.filter(
StoreRoom=storeO, year=1400, Product__id=p_id
).select_related('Product').order_by('-createdDT')
data = {
'storeO': storeO,
'operationsList': operationsList,
's_id': s_id,
'productCategory': productCategory
}
return render(request, 'admin_panel/operations.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def newOperation(request, s_id, p_id):
productCategory = ProductCategory.objects.all()
storeO = StoreRoom.objects.get(id=s_id)
allStores = StoreRoom.objects.exclude(id=s_id)
product = Product.objects.get(id=p_id)
data = {'s_id': s_id,'p_id': p_id, 'allStores': allStores, 'storeO': storeO, 'product':product, 'productCategory': productCategory}
return render(request, 'admin_panel/newOperation.html', data )
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def store_transfers(request, s_id):
"""
داشبورد و کارتابل جامع مدیریت انتقال‌های انبار
"""
storeO = get_object_or_404(StoreRoom, id=s_id)
# ۱. کارتابل ورودی (بارهایی که به سمت این انبار آمده و منتظر تایید ما هستند)
# استفاده از select_related برای جلوگیری از صدها کوئری اضافی به دیتابیس
incoming_pending = StoreTransferRequest.objects.filter(
target_store=storeO,
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'source_store', 'requester').order_by('-created_at')
# ۲. کارتابل خروجی (بارهایی که ما فرستادیم و منتظر تایید انبار مقصد هستیم)
outgoing_pending = StoreTransferRequest.objects.filter(
source_store=storeO,
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'target_store', 'requester').order_by('-created_at')
# ۳. تاریخچه انتقالات اخیر (۵۰ مورد آخر تعیین تکلیف شده برای این انبار)
history_list = StoreTransferRequest.objects.filter(
Q(source_store=storeO) | Q(target_store=storeO)
).exclude(
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'source_store', 'target_store', 'approver').order_by('-action_date')[:50]
data = {
'storeO': storeO,
's_id': s_id,
'incoming_pending': incoming_pending,
'outgoing_pending': outgoing_pending,
'history_list': history_list,
}
return render(request, 'admin_panel/store_transfers.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
@require_POST
def process_transfer_action(request, transfer_id):
"""
پردازش امن اقدامات انباردار (تایید، رد، انصراف) با تراکنش‌های اتمیک
"""
action = request.POST.get('action')
reason = request.POST.get('reason', '').strip()
# دریافت درخواست انتقال
transfer = get_object_or_404(StoreTransferRequest, id=transfer_id)
# تعیین انباری که کاربر از آنجا اقدام کرده جهت ریدایرکت صحیح
redirect_store_id = transfer.target_store_id if action in ['approve', 'reject'] else transfer.source_store_id
try:
if action == 'approve':
approve_transfer_request(request_id=transfer.id, approver=request.user)
messages.success(request, f"انتقال {transfer.quantity} عدد {transfer.product.product_name} با موفقیت تایید و به موجودی انبار اضافه شد.")
elif action == 'reject':
if not reason:
messages.error(request, "برای رد کردن بار، وارد کردن علت رد الزامی است.")
return redirect('admin_panel:store_transfers', s_id=redirect_store_id)
reject_transfer_request(request_id=transfer.id, rejector=request.user, reason=reason)
messages.warning(request, f"درخواست انتقال بار رد شد و به انبار مبدا اطلاع داده شد.")
elif action == 'cancel':
cancel_transfer_request(request_id=transfer.id, user=request.user)
messages.info(request, "درخواست انتقال توسط شما لغو گردید.")
else:
messages.error(request, "دستور ارسالی نامعتبر است.")
except ValidationError as e:
# نمایش خطاهای منطقی (مثلاً کمبود موجودی انبار مبدا در لحظه تایید)
error_text = ' '.join(e.messages) if hasattr(e, 'messages') else str(e)
messages.error(request, f"خطا در انجام عملیات: {error_text}")
except PermissionError as e:
messages.error(request, str(e))
except Exception as e:
logger.error(f"Error processing transfer action #{transfer_id}: {str(e)}", exc_info=True)
messages.error(request, "خطای سیستمی در پردازش تراکنش رخ داد.")
return redirect('admin_panel:store_transfers', s_id=redirect_store_id)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def newStore(request):
storeName = request.POST.get('storeName', '')
newStore = StoreRoom()
newStore.store_name = storeName
newStore.save()
return redirect('/panel/store/')
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def productAdd(request):
namePrd = request.POST.get('namePrd', '')
category = request.POST.get('category', '')
price = request.POST.get('price', '')
mojodiAval = request.POST.get('mojodiAval', '')
storeroom = request.POST.get('storeroom', '')
newProduct = Product()
newProduct.product_name = namePrd
newProduct.ProductCategory = ProductCategory.objects.get(id=category)
newProduct.product_price = price
newProduct.save()
prd_in_store = ProductInStore()
prd_in_store.Product = newProduct
prd_in_store.StoreRoom = StoreRoom.objects.get(id=storeroom)
prd_in_store.s_in = mojodiAval
prd_in_store.desc = "موجودی اولیه: "+str(mojodiAval)
prd_in_store.movement_type = ProductInStore.MovementType.INITIAL
prd_in_store.save()
#return HttpResponse(installDate)
return redirect('/panel/store/1')
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def productEdit(request):
prd_code = request.POST.get('prd_code', '')
namePrd = request.POST.get('namePrd', '')
category = request.POST.get('category', '')
price = request.POST.get('price', '')
#return HttpResponse(prd_code)
editProduct = Product.objects.get(id=prd_code)
editProduct.product_name = namePrd
editProduct.ProductCategory = ProductCategory.objects.get(id=category)
editProduct.product_price = price
editProduct.save()
return redirect('/panel/store/1')
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def productDel(request, p_id, s_id):
Product.objects.get(id=p_id).delete()
return redirect('/panel/store/'+str(s_id))
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operation(request):
if request.method != 'POST':
return HttpResponseBadRequest("متد ارسالی نامعتبر است.")
user_op = request.user
user_store_room = get_object_or_404(StoreRoom, owners=user_op)
# استفاده از توابع امن اختصاصی پروژه برای جداسازی مقادیر عددی
prd_id = _safe_int(request.POST.get('prd_id'))
action_type = request.POST.get('actionType', '')
tedad = _safe_int(request.POST.get('tedad'))
azanbar = _safe_int(request.POST.get('azanbar'))
beanbar = _safe_int(request.POST.get('beanbar'))
desc = request.POST.get('desc', '').strip()
if tedad <= 0:
return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.")
s_room = get_object_or_404(StoreRoom, id=azanbar)
product_obj = get_object_or_404(Product, id=prd_id)
try:
# =====================================================================
# بخش اول: انتقال بین انبارها (واگذاری کامل به لایه سرویس)
# =====================================================================
if action_type == 'transfer':
if not beanbar:
return HttpResponse("انبار مقصد مشخص نشده است.")
target_store = get_object_or_404(StoreRoom, id=_safe_int(beanbar))
# فراخوانی لایه سرویس برای ثبت درخواست انتقال (بدون تغییر مستقیم موجودی)
create_transfer_request(
source_store=s_room,
target_store=target_store,
product=product_obj,
quantity=tedad,
requester=request.user,
desc=desc or f"درخواست انتقال از انبار {s_room.store_name} به {target_store.store_name}"
)
logger.info(
f"Transfer request created: {tedad}x {product_obj.product_name} from Store #{s_room.id} to Store #{target_store.id} by User #{request.user.id}"
)
return redirect('/panel/store/' + str(s_room.id))
# =====================================================================
# بخش دوم: ورود و خروج دستی (کنترل مستقیم با Transaction و Lock)
# =====================================================================
elif action_type in ['vorod', 'khoroj']:
user_s_room = get_object_or_404(StoreRoom, owners=user_op)
if not user_op.is_superuser and s_room != user_s_room:
return HttpResponse("شما دسترسی ورود / خروج به این انبار ندارید")
with transaction.atomic():
# ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا
qs = ProductInStore.objects.filter(
Product=product_obj, StoreRoom=s_room, year=1400
)
# قفل‌گذاری ردیف‌ها برای جلوگیری از Race Condition (در دیتابیس‌های پشتیبان)
if connection.features.has_select_for_update:
qs = qs.select_for_update()
locked_records = list(qs)
# --- الف) عملیات ورود دستی ---
if action_type == 'vorod':
ProductInStore.objects.create(
StoreRoom=s_room,
Product=product_obj,
s_in=tedad,
s_out=0,
year=1400,
desc=f"ورود: {tedad} به انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.DIRECT_IN
)
# --- ب) عملیات خروج دستی ---
elif action_type == 'khoroj':
# محاسبه سریع موجودی در رم بدون فشار مجدد به دیتابیس
total_in = sum(item.s_in for item in locked_records if item.s_in)
total_out = sum(item.s_out for item in locked_records if item.s_out)
mojodi_prd = total_in - total_out
if mojodi_prd < tedad:
# تطابق ۱۰۰ درصدی با پاسخ متنی مورد انتظار فرانت‌اند
return HttpResponse("موجودی کافی نیست، موجودی فعلی: " + str(mojodi_prd))
ProductInStore.objects.create(
StoreRoom=s_room,
Product=product_obj,
s_in=0,
s_out=tedad,
year=1400,
desc=f"خروج: {tedad} از انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.DIRECT_OUT
)
else:
return HttpResponseBadRequest("نوع عملیات نامعتبر است.")
except ValidationError as e:
# مدیریت خطاهای اعتبارسنجی (مثل کمبود موجودی در لایه سرویس یا یکسان بودن انبارها)
error_msg = ' '.join(e.messages) if hasattr(e, 'messages') else str(e)
logger.warning(f"Validation error in store operation: {error_msg}")
return HttpResponse(error_msg)
except PermissionDenied as e:
# مدیریت خطای دسترسی کاربر به انبار
logger.warning(f"Permission denied in store operation for user {request.user.id}: {str(e)}")
return HttpResponse("شما دسترسی لازم برای انجام این عملیات در این انبار را ندارید.", status=403)
except Exception as e:
logger.error(f"Critical error in store operation view: {str(e)}", exc_info=True)
return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.", status=500)
return redirect('/panel/store/' + str(s_room.id))
@login_required(login_url='/panel/p_login/')
def productMojodi(request, p_id):
# پیدا کردن انبار کاربر با متد filter به جای get برای جلوگیری از کرش سیستم
store_user = StoreRoom.objects.filter(owners=request.user).first()
# اگر کاربر هنوز مالک هیچ انباری نشده است
if not store_user:
return HttpResponse("کاربر مالکیت انبار ندارد") # یا هر مقداری که فرانت‌اند انتظار دارد
# محاسبه موجودی برای انبار پیدا شده
totals = ProductInStore.objects.filter(
Product_id=p_id,
StoreRoom=store_user,
year=1400
).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
mojodi_prd = total_in - total_out
return HttpResponse(str(mojodi_prd))
+43
View File
@@ -0,0 +1,43 @@
# راه‌اندازی چهار سرویس از یک ایمیج مشترک:
# web → وب‌سرور Gunicorn (می‌گریشن/استاتیک فقط اینجا اجرا می‌شود)
# worker → تخلیه صف پیامک (sms_worker)
# scheduler → اسکن دوره‌ای و صف‌کردن یادآوری‌ها (sms_scan --loop)
# delivery → استعلام وضعیت تحویل پیامک‌ها (sms_delivery --sleep 30)
#
# توجه: دیتابیس SQLite است؛ مقیاس افقی (چند replica) ممنوع.
x-app: &app
build: .
env_file: production.env
volumes:
- ./sqlite:/app/sqlite
restart: unless-stopped
services:
web:
<<: *app
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/').read()\""]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
worker:
<<: *app
command: ["python", "manage.py", "sms_worker", "--sleep", "3"]
depends_on:
- web
scheduler:
<<: *app
command: ["python", "manage.py", "sms_scan", "--loop", "--interval", "300"]
depends_on:
- web
delivery:
<<: *app
command: ["python", "manage.py", "sms_delivery", "--sleep", "30"]
depends_on:
- web
+16 -3
View File
@@ -3,11 +3,19 @@
# ۱. متوقف کردن آنی کل فرآیند کانتینر در صورت بروز خطا در هر لایه
set -e
# اگر کامند سفارشی داده شده (worker/scheduler) می‌گریشن/استاتیک فقط در سرویس وب اجرا می‌شود
RUN_WEB_SETUP=1
if [ "$#" -gt 0 ] && [ "$1" != "gunicorn" ]; then
RUN_WEB_SETUP=0
fi
if [ "$RUN_WEB_SETUP" = "1" ]; then
echo "==> [1/4] Collecting Static Files..."
python manage.py collectstatic --noinput --clear
echo "==> [2/4] Running Database Migrations..."
python manage.py migrate --noinput
fi
echo "==> [3/4] Optimizing SQLite Storage (WAL & Synchronous)..."
# استفاده از بلاک EOF پایتون برای خوانایی، ایزولاسیون خطا و ثبت سیستماتیک لاگ‌ها
@@ -40,6 +48,11 @@ except Exception as e:
sys.exit(1)
EOF
echo "==> [4/4] Starting Gunicorn Web Server..."
# جایگزین کردن پروسس PID 1 با Gunicorn برای مدیریت اصولی سیگنال‌های توقف داکر (Graceful Shutdown)
exec gunicorn --bind 0.0.0.0:8000 khatibi.wsgi:application
echo "==> [4/4] Starting Application..."
# اگر کامند سفارشی داده شده باشد (مثل sms_worker/sms_scan) همان اجرا می‌شود؛
# در غیر این‌صورت وب‌سرور Gunicorn به‌عنوان PID 1 جایگزین می‌شود (Graceful Shutdown).
if [ "$#" -gt 0 ]; then
exec "$@"
else
exec gunicorn --bind 0.0.0.0:8000 khatibi.wsgi:application
fi
+121
View File
@@ -0,0 +1,121 @@
# اصلاح دستی شماره‌های قدیمی مشتریان
## چرا؟
فیلد‌های `phone1` / `phone2` / `phone3` در مدل `Customer` از نوع `CharField` هستند و
سال‌ها داده در آن‌ها به هر شکلی وارد شده: بدون صفر ابتدایی (`9133722815`)، ارقام
فارسی، فاصله/خط تیره، شمارهٔ ثابت ۸رقمی، یا کلاهی متن آزاد (نام، تاریخ نصب، «معرف …»).
هدف: هرچه در فیلد شماره هست پاک‌سازی شود، موبایل به شکل استاندارد `09xxxxxxxxx`
در بیاید، و **هیچ متنی از سیستم حذف نشود** — متن‌های برداشته‌شده در فیلد
`Customer.notes` (توضیحات) می‌مانند و در پنل، ستون سمت راست صفحهٔ مشتری نمایش
داده می‌شوند.
> این کار **یک‌بار** لازم است. دستور idempotent است؛ اجرای دوباره‌اش هیچ تغییری
> ایجاد نمی‌کند و یادداشتی را تکرار نمی‌کند.
## قاعدهٔ پاک‌سازی
| ورودی (نمونه) | نتیجه |
|---|---|
| `9133722815` | `09133722815` (و اصل متن در توضیحات) |
| `۰۹۱۳۳۷۲۲۸۱۵` | `09133722815` (و اصل متن در توضیحات) |
| `0913-372-2815` | `09133722815` (و اصل متن در توضیحات) |
| `57322182/9130261671` | `09130261671` (کل متن اصلی در توضیحات) |
| `57387608` (ثابت ۸رقمی) | **دست‌نخورده** |
| `اعظمی` (متن آزاد) | خالی؛ `اعظمی` در توضیحات |
| `0` (تکهٔ بی‌معنی) | خالی؛ `0` در توضیحات |
خلاصه: هرچیزی که در مقدار جدید نمی‌ماند، بی‌کم‌وکاست در `notes` ثبت می‌شود.
## اجرای دستور
دستور منیجمنت: `fix_customer_phones`
### ۱) اول «شبیه‌سازی» — بدون هیچ تغییری
فقط گزارش می‌دهد چه چیزی تغییر می‌کند. **قبل از هر اجرای واقعی این را بزنید.**
از WSL:
```bash
cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py fix_customer_phones --dry-run
```
از ویندوز (PowerShell / CMD):
```bat
cd D:\projects\khatibi
.venv\Scripts\python.exe manage.py fix_customer_phones --dry-run
```
خروجی نمونه:
```
شبیه‌سازی (بدون تغییر): 653 مشتری، 700 فیلد اصلاح شد، 640 یادداشت ثبت شد.
```
### ۲) اجرای واقعی
```bash
.venv/Scripts/python.exe manage.py fix_customer_phones
```
خروجی نمونه:
```
پاک‌سازی انجام شد: 653 مشتری، 700 فیلد اصلاح شد، 640 یادداشت ثبت شد.
```
### ۳) اجرای دوباره برای اطمینان (اختیاری)
دوباره بزنید؛ باید صفر باشد:
```
پاک‌سازی انجام شد: 0 مشتری، 0 فیلد اصلاح شد، 0 یادداشت ثبت شد.
```
## اجرا روی سرور اصلی (production)
دستور برای همین کار نوشته شده: روی دیتابیس production هم بی‌خطر است.
```bash
cd /path/to/khatibi # مسیر پروژه روی سرور
source .venv/bin/activate # یا مسیر venv سرور
python manage.py fix_customer_phones --dry-run # اول شبیه‌سازی
python manage.py fix_customer_phones # سپس اجرا
```
**توصیهٔ مهم:** پیش از اجرای واقعی روی production یک بکاپ از دیتابیس بگیرید
(دستور `sqlite_db_backup` در همین پروژه موجود است). شبیه‌سازی فقط می‌خواند و
بی‌خطر است، ولی بکاپ عادت خوبی است.
## راه جایگزین: مهاجرت (migration)
هنگام `manage.py migrate`، مهاجرت `0067_customer_notes` هم فیلد `notes` را می‌سازد
و هم **همین پاک‌سازی را یک‌بار** روی دیتابیس اجرا می‌کند. پس اگر سرور را با
`migrate` به‌روز می‌کنید، لازم نیست دستور را جدا بزنید؛ نتیجه یکی است.
هر دو مسیر به یک پیاده‌سازی مشترک
(`admin_panel/sms/phone_cleanup.py`) تکیه دارند.
## مشاهدهٔ نتیجه
پس از اجرا، به `/panel/customer/<id>` بروید. فیلد **توضیحات** در ستون سمت راست
(کنار نام، آدرس، شماره‌های تماس) متن‌های برداشته‌شده را نشان می‌دهد، با سرصفحهٔ:
```
— پاک‌سازی خودکار فیلدهای تلفن —
«phone3»: نصب جدید 1401/07/02
```
این فیلد در حالت «ویرایش» قابل تغییر است و مقدارش ذخیره می‌شود.
## نکته‌ها
- شماره‌های ثابت ۸رقمی طبق تصمیم، **دست‌نخورده** می‌مانند.
- جدول `PhoneNumber` (شماره‌های جدید) دست‌کاری نمی‌شود؛ فقط فیلدهای قدیمی.
- اگر دستور را با قاعدهٔ قدیمی روی یک دیتابیس اجرا کرده باشید، تکه‌های بی‌معنی
(`0`، `573`) که آن زمان بدون یادداشت پاک شده‌اند قابل بازیابی نیستند. روی
production که هنوز اجرا نشده، همه‌چیز کامل حفظ می‌شود.
+999
View File
@@ -0,0 +1,999 @@
{
"communities": {
"0": [
"abstractbaseuser",
"admin_panel_models",
"admin_panel_models_articletype",
"admin_panel_models_cachetable",
"admin_panel_models_cachetable_str",
"admin_panel_models_phonenumber",
"admin_panel_models_phonenumber_str",
"admin_panel_models_product",
"admin_panel_models_product_meta",
"admin_panel_models_product_str",
"admin_panel_models_productstockhistory",
"admin_panel_models_productstockhistory_meta",
"admin_panel_models_productstockhistory_str",
"admin_panel_models_rationale_97",
"admin_panel_models_storeroomowner",
"admin_panel_models_storetransferrequest",
"admin_panel_models_storetransferrequest_clean",
"admin_panel_models_storetransferrequest_meta",
"admin_panel_models_storetransferrequest_status",
"admin_panel_models_storetransferrequest_str",
"admin_panel_models_userprofile",
"admin_panel_models_userprofile_str",
"admin_panel_models_visitqueue",
"admin_panel_services",
"admin_panel_services_approve_transfer_request",
"admin_panel_services_cancel_transfer_request",
"admin_panel_services_check_source_store_permission",
"admin_panel_services_check_target_store_permission",
"admin_panel_services_create_transfer_request",
"admin_panel_services_get_product_current_stock",
"admin_panel_services_rationale_160",
"admin_panel_services_rationale_187",
"admin_panel_services_rationale_20",
"admin_panel_services_rationale_30",
"admin_panel_services_rationale_40",
"admin_panel_services_rationale_64",
"admin_panel_services_rationale_99",
"admin_panel_services_reject_transfer_request",
"admin_panel_views_process_transfer_action",
"admin_panel_views_rationale_1320",
"atomic",
"django_contrib_auth",
"django_contrib_auth_models",
"django_core_exceptions",
"django_db_models",
"django_dispatch",
"django_utils_translation",
"typing"
],
"1": [
"admin_panel_context_processor",
"admin_panel_context_processor_dashboxdata",
"admin_panel_management_commands_send_daily_report",
"admin_panel_management_commands_send_daily_report_command",
"admin_panel_management_commands_send_daily_report_command_handle",
"admin_panel_management_commands_send_daily_report_py_basecommand",
"admin_panel_models_article",
"admin_panel_models_article_meta",
"admin_panel_models_customer",
"admin_panel_models_customer_delete",
"admin_panel_models_customer_meta",
"admin_panel_models_customer_str",
"admin_panel_models_factor",
"admin_panel_models_factor_delete",
"admin_panel_models_factor_meta",
"admin_panel_models_factoriteam",
"admin_panel_models_sarresid",
"admin_panel_templatetags_custom_tags",
"admin_panel_templatetags_custom_tags_has_group",
"admin_panel_templatetags_custom_tags_tojalali",
"admin_panel_templatetags_remove_q_p",
"admin_panel_templatetags_remove_q_p_rationale_7",
"admin_panel_templatetags_remove_q_p_remove_query_param",
"admin_panel_views_customerhesabdariadd",
"admin_panel_views_customersadd",
"admin_panel_views_newfactor",
"check_finance",
"check_finance_check_financial_discrepancy",
"csv",
"datetime",
"django",
"django_core_asgi",
"django_core_wsgi",
"django_db_models_functions",
"django_utils",
"filter",
"ghost_hunter",
"ghost_hunter_run_360_audit",
"khatibi_asgi",
"khatibi_asgi_rationale_1",
"khatibi_wsgi",
"khatibi_wsgi_rationale_1",
"os",
"persiantools_jdatetime",
"reconciliation_tool",
"reconciliation_tool_run_audit",
"requests"
],
"2": [
"admin_panel_management_commands_gt_esh",
"admin_panel_management_commands_gt_esh_command",
"admin_panel_management_commands_gt_esh_command_add_arguments",
"admin_panel_management_commands_gt_esh_command_execute_by_immediate_chunks",
"admin_panel_management_commands_gt_esh_command_execute_hybrid_migration",
"admin_panel_management_commands_gt_esh_command_handle",
"admin_panel_management_commands_gt_esh_command_process_single_chunk",
"admin_panel_management_commands_gt_esh_py_basecommand",
"admin_panel_management_commands_gt_esh_rationale_115",
"admin_panel_management_commands_gt_esh_rationale_123",
"admin_panel_management_commands_gt_esh_rationale_89",
"admin_panel_management_commands_sqlite_db_backup",
"admin_panel_management_commands_sqlite_db_backup_command",
"admin_panel_management_commands_sqlite_db_backup_command_cleanup_resources",
"admin_panel_management_commands_sqlite_db_backup_command_handle",
"admin_panel_management_commands_sqlite_db_backup_command_handle_critical_failure",
"admin_panel_management_commands_sqlite_db_backup_command_handle_progress_callback",
"admin_panel_management_commands_sqlite_db_backup_command_init",
"admin_panel_management_commands_sqlite_db_backup_command_signal_handler",
"admin_panel_management_commands_sqlite_db_backup_py_basecommand",
"admin_panel_management_commands_sqlite_db_backup_rationale_158",
"admin_panel_management_commands_sqlite_db_backup_rationale_30",
"boto3",
"botocore_config",
"botocore_exceptions",
"contextlib",
"django_core_management_base",
"fcntl",
"khatibi_production_settings",
"khatibi_settings",
"khatibi_settings_rationale_1",
"logging",
"pathlib",
"shutil",
"signal",
"sqlite3",
"time"
],
"3": [
"admin_panel_migrations_0004_factor_paytype",
"admin_panel_migrations_0004_factor_paytype_migration",
"admin_panel_migrations_0013_alter_factor_options",
"admin_panel_migrations_0013_alter_factor_options_migration",
"admin_panel_migrations_0014_auto_20220123_1348",
"admin_panel_migrations_0014_auto_20220123_1348_migration",
"admin_panel_migrations_0017_factoriteam_garantyesh",
"admin_panel_migrations_0017_factoriteam_garantyesh_migration",
"admin_panel_migrations_0020_auto_20220126_1532",
"admin_panel_migrations_0020_auto_20220126_1532_migration",
"admin_panel_migrations_0021_alter_article_type",
"admin_panel_migrations_0021_alter_article_type_migration",
"admin_panel_migrations_0022_article_typepy",
"admin_panel_migrations_0022_article_typepy_migration",
"admin_panel_migrations_0023_alter_article_typepy",
"admin_panel_migrations_0023_alter_article_typepy_migration",
"admin_panel_migrations_0027_alter_action_nazar",
"admin_panel_migrations_0027_alter_action_nazar_migration",
"admin_panel_migrations_0029_alter_article_options",
"admin_panel_migrations_0029_alter_article_options_migration",
"admin_panel_migrations_0034_alter_article_sarresid",
"admin_panel_migrations_0034_alter_article_sarresid_migration",
"admin_panel_migrations_0035_alter_article_sarresid",
"admin_panel_migrations_0035_alter_article_sarresid_migration",
"admin_panel_migrations_0037_sarresid_sarresiddt",
"admin_panel_migrations_0037_sarresid_sarresiddt_migration",
"admin_panel_migrations_0040_customer_laghv_bazdid",
"admin_panel_migrations_0040_customer_laghv_bazdid_migration",
"admin_panel_migrations_0047_alter_productinstore_movement_type",
"admin_panel_migrations_0047_alter_productinstore_movement_type_migration",
"admin_panel_migrations_0055_action_nextnazardt",
"admin_panel_migrations_0055_action_nextnazardt_migration",
"django_db"
],
"4": [
"admin_panel_templates_admin_panel_customeridbackup_html",
"admin_panel_templates_admin_panel_customeridbackup_html_all_products",
"admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist",
"admin_panel_templates_admin_panel_customeridbackup_html_orderlist",
"admin_panel_templates_admin_panel_customeridfactorbackup_html",
"admin_panel_templates_admin_panel_customeridfactorbackup_html_js",
"admin_panel_templates_admin_panel_customernewbackup_html",
"admin_panel_urls_customer",
"admin_panel_urls_customeredit",
"admin_panel_urls_newfactor",
"api_endpoint_panel_customer_del",
"api_endpoint_panel_productmojodi",
"concept_category",
"concept_client_side_form_logic",
"concept_customer",
"concept_factor",
"concept_permission",
"concept_product",
"concept_productcategory",
"concept_user",
"django_concepts_csrf_token",
"django_contrib_staticfiles_templatetags_static",
"django_template_filter_tojalali",
"django_template_tag_custom_tags",
"django_template_tags_custom_tags",
"django_template_tags_static",
"django_url_admin_panel_customeredit",
"django_url_admin_panel_customerfactors",
"django_url_admin_panel_newfactor",
"django_url_admin_panel_productsjson",
"js_library_pdatepicker",
"panel_urls_productmojodi"
],
"5": [
"admin_panel_views",
"admin_panel_views_actionjson",
"admin_panel_views_api_check_status_inquiry",
"admin_panel_views_customerjson",
"admin_panel_views_customers",
"admin_panel_views_p_login",
"admin_panel_views_p_logout",
"admin_panel_views_parse_cheque_result_html",
"admin_panel_views_rationale_2852",
"admin_panel_views_run_cbi_sayad_inquiry_logic",
"admin_panel_views_solve_base64_captcha",
"asgiref_sync",
"asyncio",
"base64",
"csrf_exempt",
"cv2",
"ddddocr",
"django_contrib_auth_decorators",
"django_core_paginator",
"django_urls_base",
"django_views_decorators_csrf",
"django_views_decorators_http",
"json",
"numpy",
"persiantools",
"playwright_async_api",
"re"
],
"6": [
"admin_panel_views_addsarresid",
"admin_panel_views_bedehi",
"admin_panel_views_bedehikhadamat",
"admin_panel_views_bedehinasb",
"admin_panel_views_customerdel",
"admin_panel_views_customerssearch",
"admin_panel_views_entezar",
"admin_panel_views_factordel",
"admin_panel_views_financial_dashboard",
"admin_panel_views_hesabdari",
"admin_panel_views_hesabdari_checkpass",
"admin_panel_views_hesabdari_checkpassfail",
"admin_panel_views_hesabdari_residnashone",
"admin_panel_views_pardakhtnesiye",
"admin_panel_views_passcheck",
"admin_panel_views_productdel",
"admin_panel_views_rationale_395",
"admin_panel_views_report",
"admin_panel_views_report_debtors_no_vosol",
"admin_panel_views_rezayat",
"admin_panel_views_rezayatedit",
"admin_panel_views_sarresidh",
"admin_panel_views_update_check_info",
"login_required",
"permission_required",
"require_post"
],
"7": [
"admin_panel_templates_admin_panel_base",
"admin_panel_templates_admin_panel_basehesabdari",
"admin_panel_templates_admin_panel_basestore",
"admin_panel_templates_admin_panel_customerfactorbackup",
"concept_django_custom_tags",
"concept_url_admin_panel_actionlistusertoday",
"concept_url_admin_panel_bedehi",
"concept_url_admin_panel_customers",
"concept_url_admin_panel_entezar",
"concept_url_admin_panel_hesabdari",
"concept_url_admin_panel_hesabdari_residnashone",
"concept_url_admin_panel_index",
"concept_url_admin_panel_operations",
"concept_url_admin_panel_p_logout",
"concept_url_admin_panel_productadd",
"concept_url_admin_panel_rezayat",
"concept_url_admin_panel_store",
"concept_url_admin_panel_store_transfers",
"concept_url_admin_panel_stores",
"concept_url_panel_customer_factor_del",
"static_admin_panel_bulma_min_css",
"static_admin_panel_datatables_datatables_min_js",
"static_admin_panel_kamadatepicker_min_js",
"static_admin_panel_persian-datepicker_min_js",
"static_admin_panel_style_css"
],
"8": [
"admin_panel_migrations_0001_initial",
"admin_panel_migrations_0001_initial_migration",
"admin_panel_migrations_0007_productinstore_factor",
"admin_panel_migrations_0007_productinstore_factor_migration",
"admin_panel_migrations_0008_storeroom_user",
"admin_panel_migrations_0008_storeroom_user_migration",
"admin_panel_migrations_0018_alter_factoriteam_product",
"admin_panel_migrations_0018_alter_factoriteam_product_migration",
"admin_panel_migrations_0019_article_articletype",
"admin_panel_migrations_0019_article_articletype_migration",
"admin_panel_migrations_0024_action",
"admin_panel_migrations_0024_action_migration",
"admin_panel_migrations_0036_sarresid",
"admin_panel_migrations_0036_sarresid_migration",
"admin_panel_migrations_0038_auto_20240213_1735",
"admin_panel_migrations_0038_auto_20240213_1735_migration",
"admin_panel_migrations_0042_article_user",
"admin_panel_migrations_0042_article_user_migration",
"admin_panel_migrations_0046_auto_20260624_1818",
"admin_panel_migrations_0046_auto_20260624_1818_migration",
"admin_panel_migrations_0054_auto_20260715_2250",
"admin_panel_migrations_0054_auto_20260715_2250_migration",
"django_db_models_deletion"
],
"9": [
"admin_panel_migrations_0011_storeroom_user",
"admin_panel_migrations_0011_storeroom_user_migration",
"admin_panel_migrations_0012_visitqueue_user",
"admin_panel_migrations_0012_visitqueue_user_migration",
"admin_panel_migrations_0015_auto_20220124_0858",
"admin_panel_migrations_0015_auto_20220124_0858_migration",
"admin_panel_migrations_0016_factor_user",
"admin_panel_migrations_0016_factor_user_migration",
"admin_panel_migrations_0026_auto_20220209_1924",
"admin_panel_migrations_0026_auto_20220209_1924_migration",
"admin_panel_migrations_0043_sarresid_user",
"admin_panel_migrations_0043_sarresid_user_migration",
"admin_panel_migrations_0045_auto_20260610_1421",
"admin_panel_migrations_0045_auto_20260610_1421_migration",
"admin_panel_migrations_0050_storetransferrequest",
"admin_panel_migrations_0050_storetransferrequest_migration",
"admin_panel_migrations_0051_auto_20260705_1356",
"admin_panel_migrations_0051_auto_20260705_1356_migration",
"admin_panel_migrations_0052_storeroomowner",
"admin_panel_migrations_0052_storeroomowner_migration",
"admin_panel_migrations_0053_storeroom_owners",
"admin_panel_migrations_0053_storeroom_owners_migration",
"django_conf"
],
"10": [
"admin_panel_templates_admin_panel_login",
"admin_panel_templates_admin_panel_storebk1",
"django_csrf_token",
"django_custom_tags",
"django_static_files",
"django_url_admin_panel_newstore",
"django_url_admin_panel_operations",
"django_url_admin_panel_p_login",
"django_url_admin_panel_store",
"form_field_password",
"form_field_storename",
"form_field_username",
"javascript_kamadatepicker",
"product_property_mojodi",
"product_property_product_name",
"static_admin_panel_bulma-rtl_min_css",
"template_variable_message",
"template_variable_productsum",
"template_variable_s_id"
],
"11": [
"admin_panel_models_productcategory",
"admin_panel_models_productcategory_str",
"admin_panel_models_productinstore",
"admin_panel_models_productinstore_delete",
"admin_panel_models_productinstore_meta",
"admin_panel_models_productinstore_movementtype",
"admin_panel_models_productinstore_save",
"admin_panel_models_productinstore_str",
"admin_panel_views_customer",
"admin_panel_views_customerfactor",
"admin_panel_views_customernew",
"admin_panel_views_customernew2",
"admin_panel_views_operationproduct",
"admin_panel_views_operations",
"admin_panel_views_productadd",
"admin_panel_views_productedit",
"admin_panel_views_store"
],
"12": [
"admin_panel_templates_admin_panel_bedehbk",
"admin_panel_templates_admin_panel_customeraction",
"admin_panel_templates_admin_panel_customeractioninc_html",
"admin_panel_templates_admin_panel_customersbk_html",
"admin_panel_url_customernew",
"admin_panel_urls_customernew",
"admin_panel_urls_customernewaction",
"concept_action_model",
"concept_datatables",
"concept_jquery",
"django_template_tag_static",
"django_template_tag_url",
"library_bulma",
"library_datatables",
"library_pdatepicker"
],
"13": [
"admin_models_user",
"admin_panel_models_action",
"admin_panel_views_actionlist",
"admin_panel_views_actionlistall",
"admin_panel_views_actionlisttoday",
"admin_panel_views_actionlistuser",
"admin_panel_views_actionlistusertoday",
"admin_panel_views_customeraction",
"admin_panel_views_customereditaction",
"admin_panel_views_customernazar",
"admin_panel_views_customernewaction",
"admin_panel_views_rezayat_dashboard"
],
"14": [
"admin_panel_templates_admin_panel_bedehi_html",
"admin_panel_templates_admin_panel_hesabdaricheckpass_html",
"admin_panel_urls_passcheck",
"concept_all_sarresid",
"django_context_variable_mandehesabkp",
"django_templatetags_tojalali",
"django_urls_admin_panel_bedehi",
"django_urls_admin_panel_bedehikhadamat",
"django_urls_admin_panel_bedehinasb",
"django_urls_admin_panel_customer",
"javascript_jquery"
],
"15": [
"admin_admin",
"admin_panel_admin",
"admin_panel_admin_rationale_54",
"admin_panel_admin_rationale_75",
"admin_panel_admin_storeroomadmin",
"admin_panel_admin_storeroomadmin_get_owners_count",
"admin_panel_admin_storeroomownerinline",
"admin_panel_admin_storetransferrequestadmin",
"django_contrib",
"register"
],
"16": [
"admin_models",
"admin_models_customer",
"admin_models_factor",
"admin_models_factoriteam",
"admin_models_phonenumber",
"admin_models_product",
"admin_models_productcategory",
"admin_models_productinstore",
"admin_models_storeroom",
"admin_models_visitqueue"
],
"17": [
"admin_panel_templates_admin_panel_customerbase",
"admin_panel_templates_admin_panel_customerid",
"admin_panel_views_customer_delete",
"admin_panel_views_customeredit",
"admin_panel_views_customerhesabdari",
"admin_panel_views_productsjson",
"concept_url_admin_panel_newfactor",
"django_contrib_auth_models_permission",
"django_templatetags_static",
"javascript_pdatepicker"
],
"18": [
"admin_panel_data_customer",
"admin_panel_data_factor",
"admin_panel_data_sarresid",
"admin_panel_templates_admin_panel_hesabdaricheckpassfail_html",
"admin_panel_url_pardakhtnesiye",
"admin_panel_url_passcheck",
"django_permission_viewreportmali",
"javascript_library_jquery",
"javascript_library_pdatepicker"
],
"19": [
"admin_panel_models_storeroom",
"admin_panel_models_storeroom_meta",
"admin_panel_models_storeroom_str",
"admin_panel_views_newoperation",
"admin_panel_views_newstore",
"admin_panel_views_productmojodi",
"admin_panel_views_rationale_1281",
"admin_panel_views_store_transfers",
"admin_panel_views_stores"
],
"20": [
"admin_panel_views_customerfactors",
"admin_panel_views_inventoryexception",
"admin_panel_views_newfactor2",
"admin_panel_views_parse_jalali_date",
"admin_panel_views_parse_timestamp",
"admin_panel_views_rationale_39",
"admin_panel_views_rationale_53",
"admin_panel_views_rationale_65",
"exception"
],
"21": [
"copilot_chat",
"github_copilot_instructions",
"github_copilot_instructions_graphify",
"graphify-out_graph_json",
"graphify-out_graph_report_md",
"graphify-out_wiki_index_md",
"graphify_explain",
"graphify_path",
"graphify_query"
],
"22": [
"admin_panel_templates_admin_panel_hesabdariresidnashode",
"admin_panel_urls_pardakhtnesiye",
"django_permissions_admin_panel_viewreportmali",
"lib_jquery",
"lib_pdatepicker",
"templatetags_custom_tags",
"templatetags_custom_tags_tocurrency",
"templatetags_custom_tags_tojalali"
],
"23": [
"admin_panel_views_api_check_sabt_inquiry",
"admin_panel_views_inventory_movements_report",
"admin_panel_views_operation",
"admin_panel_views_rationale_2875",
"admin_panel_views_rationale_313",
"admin_panel_views_rationale_43",
"admin_panel_views_run_bpi_cheque_inquiry_logic",
"admin_panel_views_safe_int"
],
"24": [
"admin_apps",
"admin_apps_adminconfig",
"admin_apps_py_appconfig",
"admin_panel_apps",
"admin_panel_apps_adminpanelconfig",
"admin_panel_apps_py_appconfig",
"django_apps"
],
"25": [
"admin_panel_migrations_0002_auto_20220114_1616",
"admin_panel_migrations_0002_auto_20220114_1616_migration",
"admin_panel_migrations_0003_auto_20220115_1401",
"admin_panel_migrations_0003_auto_20220115_1401_migration",
"admin_panel_migrations_0031_article_sarresid",
"admin_panel_migrations_0031_article_sarresid_migration",
"django_utils_timezone"
],
"26": [
"entrypoint",
"entrypoint_sh__entry",
"manage",
"manage_main",
"manage_rationale_2",
"manage_rationale_8",
"sys"
],
"27": [
"admin_init",
"admin_urls",
"admin_views",
"admin_views_index",
"django_http",
"django_shortcuts"
],
"28": [
"admin_panel_init",
"admin_panel_urls",
"django_conf_urls_static",
"django_urls",
"khatibi_urls",
"khatibi_urls_rationale_1"
],
"29": [
"admin_panel_templates_admin_panel_entezar_html",
"concept_customer_data",
"concept_kamadatepicker",
"django_templatetags_custom_tags",
"django_templatetags_custom_tags_tojalali"
],
"30": [
"admin_panel_migrations_0056_auto_20260731_1824",
"admin_panel_migrations_0056_auto_20260731_1824_migration",
"django_core_validators"
],
"31": [
"admin_panel_templates_admin_panel_customersbk_html_createcs",
"admin_panel_templates_admin_panel_customersbk_html_openrow",
"concept_customer_modal_ui"
],
"32": [
"admin_panel_tests",
"admin_tests",
"django_test"
],
"33": [
"concept_articl_model",
"concept_customer_model",
"django_context_variable_articls"
],
"34": [
"admin_panel_migrations_0005_auto_20220121_2054",
"admin_panel_migrations_0005_auto_20220121_2054_migration"
],
"35": [
"admin_panel_migrations_0006_auto_20220121_2100",
"admin_panel_migrations_0006_auto_20220121_2100_migration"
],
"36": [
"admin_panel_migrations_0009_remove_storeroom_user",
"admin_panel_migrations_0009_remove_storeroom_user_migration"
],
"37": [
"admin_panel_migrations_0010_auto_20220122_1546",
"admin_panel_migrations_0010_auto_20220122_1546_migration"
],
"38": [
"admin_panel_migrations_0025_action_actiontype",
"admin_panel_migrations_0025_action_actiontype_migration"
],
"39": [
"admin_panel_migrations_0028_action_grade",
"admin_panel_migrations_0028_action_grade_migration"
],
"40": [
"admin_panel_migrations_0030_auto_20220414_1047",
"admin_panel_migrations_0030_auto_20220414_1047_migration"
],
"41": [
"admin_panel_migrations_0032_article_paystatus",
"admin_panel_migrations_0032_article_paystatus_migration"
],
"42": [
"admin_panel_migrations_0033_article_nahvepardakht",
"admin_panel_migrations_0033_article_nahvepardakht_migration"
],
"43": [
"admin_panel_migrations_0039_customer_last_bazdid_dt",
"admin_panel_migrations_0039_customer_last_bazdid_dt_migration"
],
"44": [
"admin_panel_migrations_0041_auto_20240216_1607",
"admin_panel_migrations_0041_auto_20240216_1607_migration"
],
"45": [
"admin_panel_migrations_0044_sarresid_desc",
"admin_panel_migrations_0044_sarresid_desc_migration"
],
"46": [
"admin_panel_migrations_0048_alter_productinstore_options",
"admin_panel_migrations_0048_alter_productinstore_options_migration"
],
"47": [
"admin_panel_migrations_0049_alter_productinstore_options",
"admin_panel_migrations_0049_alter_productinstore_options_migration"
],
"48": [
"gemini",
"gemini_graphify"
],
"49": [
"admin_migrations_init"
],
"50": [
"admin_panel_customer_action_inc"
],
"51": [
"admin_panel_customer_base_new"
],
"52": [
"admin_panel_customer_customernazarinc"
],
"53": [
"admin_panel_customer_factor"
],
"54": [
"admin_panel_customer_hesabdari"
],
"55": [
"admin_panel_customer_id_factor"
],
"56": [
"admin_panel_customer_nazar"
],
"57": [
"admin_panel_management_commands_init"
],
"58": [
"admin_panel_management_init"
],
"59": [
"admin_panel_migrations_init"
],
"60": [
"admin_panel_templates_admin_panel_actionlist"
],
"61": [
"admin_panel_templates_admin_panel_base_html"
],
"62": [
"admin_panel_templates_admin_panel_customerNew"
],
"63": [
"admin_panel_templates_admin_panel_customerNew2"
],
"64": [
"admin_panel_templates_admin_panel_customerRezayat"
],
"65": [
"admin_panel_templates_admin_panel_customerRezayatinc"
],
"66": [
"admin_panel_templates_admin_panel_customerSarresid"
],
"67": [
"admin_panel_templates_admin_panel_customerbasenew_html"
],
"68": [
"admin_panel_templates_admin_panel_customers"
],
"69": [
"admin_panel_templates_admin_panel_entezarbk"
],
"70": [
"admin_panel_templates_admin_panel_financial_dashboard"
],
"71": [
"admin_panel_templates_admin_panel_hesabdari"
],
"72": [
"admin_panel_templates_admin_panel_index"
],
"73": [
"admin_panel_templates_admin_panel_newOperation"
],
"74": [
"admin_panel_templates_admin_panel_operations"
],
"75": [
"admin_panel_templates_admin_panel_operationsbk"
],
"76": [
"admin_panel_templates_admin_panel_report"
],
"77": [
"admin_panel_templates_admin_panel_report_debtors_no_vosol"
],
"78": [
"admin_panel_templates_admin_panel_report_inventory_movements"
],
"79": [
"admin_panel_templates_admin_panel_report_rezayat"
],
"80": [
"admin_panel_templates_admin_panel_reportchecknesiye"
],
"81": [
"admin_panel_templates_admin_panel_rezayat"
],
"82": [
"admin_panel_templates_admin_panel_store"
],
"83": [
"admin_panel_templates_admin_panel_store_transfers"
],
"84": [
"admin_panel_templates_admin_panel_stores"
],
"85": [
"admin_panel_templatetags_custom_tags_tocurrency"
],
"86": [
"admin_panel_templatetags_init"
],
"87": [
"admin_panel_urls_newstore"
],
"88": [
"admin_panel_urls_processtransferaction"
],
"89": [
"admin_panel_urls_rezayatedit"
],
"90": [
"khatibi_init"
],
"91": [
"requirements2_txt"
],
"92": [
"requirements_txt"
]
},
"cohesion": {
"0": 0.0673758865248227,
"1": 0.07123034227567067,
"2": 0.06606606606606606,
"3": 0.06060606060606061,
"4": 0.06653225806451613,
"5": 0.08262108262108261,
"6": 0.12923076923076923,
"7": 0.08333333333333333,
"8": 0.08695652173913043,
"9": 0.08695652173913043,
"10": 0.1111111111111111,
"11": 0.14705882352941177,
"12": 0.1523809523809524,
"13": 0.3181818181818182,
"14": 0.2,
"15": 0.2222222222222222,
"16": 0.2,
"17": 0.2,
"18": 0.2222222222222222,
"19": 0.2222222222222222,
"20": 0.2222222222222222,
"21": 0.2222222222222222,
"22": 0.32142857142857145,
"23": 0.25,
"24": 0.2857142857142857,
"25": 0.2857142857142857,
"26": 0.2857142857142857,
"27": 0.3333333333333333,
"28": 0.4666666666666667,
"29": 0.4,
"30": 0.6666666666666666,
"31": 0.6666666666666666,
"32": 0.6666666666666666,
"33": 0.6666666666666666,
"34": 1.0,
"35": 1.0,
"36": 1.0,
"37": 1.0,
"38": 1.0,
"39": 1.0,
"40": 1.0,
"41": 1.0,
"42": 1.0,
"43": 1.0,
"44": 1.0,
"45": 1.0,
"46": 1.0,
"47": 1.0,
"48": 1.0,
"49": 1.0,
"50": 1.0,
"51": 1.0,
"52": 1.0,
"53": 1.0,
"54": 1.0,
"55": 1.0,
"56": 1.0,
"57": 1.0,
"58": 1.0,
"59": 1.0,
"60": 1.0,
"61": 1.0,
"62": 1.0,
"63": 1.0,
"64": 1.0,
"65": 1.0,
"66": 1.0,
"67": 1.0,
"68": 1.0,
"69": 1.0,
"70": 1.0,
"71": 1.0,
"72": 1.0,
"73": 1.0,
"74": 1.0,
"75": 1.0,
"76": 1.0,
"77": 1.0,
"78": 1.0,
"79": 1.0,
"80": 1.0,
"81": 1.0,
"82": 1.0,
"83": 1.0,
"84": 1.0,
"85": 1.0,
"86": 1.0,
"87": 1.0,
"88": 1.0,
"89": 1.0,
"90": 1.0,
"91": 1.0,
"92": 1.0
},
"gods": [
{
"id": "admin_panel_models_storeroom",
"label": "StoreRoom",
"degree": 25
},
{
"id": "admin_panel_models_productinstore",
"label": "ProductInStore",
"degree": 23
},
{
"id": "admin_models_user",
"label": "User",
"degree": 21
},
{
"id": "admin_panel_models_action",
"label": "Action",
"degree": 20
},
{
"id": "admin_panel_models_product",
"label": "Product",
"degree": 18
},
{
"id": "admin_panel_models_storetransferrequest",
"label": "StoreTransferRequest",
"degree": 15
},
{
"id": "admin_panel_models_article",
"label": "Article",
"degree": 15
},
{
"id": "admin_panel_templates_admin_panel_customeridbackup_html",
"label": "Customer ID Backup/New Factor Template",
"degree": 15
},
{
"id": "admin_panel_models_productcategory",
"label": "ProductCategory",
"degree": 14
},
{
"id": "admin_panel_models_customer",
"label": "Customer",
"degree": 14
}
],
"surprises": [
{
"source": "VisitQueue",
"target": "User",
"source_files": [
"admin_panel/models.py",
"admin/models.py"
],
"confidence": "INFERRED",
"relation": "uses",
"why": "inferred connection - not explicitly stated in source; connects across different repos/directories; bridges separate communities; peripheral node `VisitQueue` unexpectedly reaches hub `User`"
},
{
"source": "Factor",
"target": "User",
"source_files": [
"admin_panel/models.py",
"admin/models.py"
],
"confidence": "INFERRED",
"relation": "uses",
"why": "inferred connection - not explicitly stated in source; connects across different repos/directories; bridges separate communities"
},
{
"source": "StoreRoom",
"target": "User",
"source_files": [
"admin_panel/models.py",
"admin/models.py"
],
"confidence": "INFERRED",
"relation": "uses",
"why": "inferred connection - not explicitly stated in source; connects across different repos/directories; bridges separate communities"
},
{
"source": "StoreRoomOwner",
"target": "User",
"source_files": [
"admin_panel/models.py",
"admin/models.py"
],
"confidence": "INFERRED",
"relation": "uses",
"why": "inferred connection - not explicitly stated in source; connects across different repos/directories; bridges separate communities"
},
{
"source": "StoreTransferRequest",
"target": "User",
"source_files": [
"admin_panel/models.py",
"admin/models.py"
],
"confidence": "INFERRED",
"relation": "uses",
"why": "inferred connection - not explicitly stated in source; connects across different repos/directories; bridges separate communities"
}
],
"tokens": {
"input": 76900,
"output": 126792
}
}
+142
View File
@@ -0,0 +1,142 @@
{
"0": "views/store.py",
"1": "project_tree.py",
"2": "sqlite_db_backup.py",
"3": "django_db",
"4": "Customer ID Backup/New Factor Template",
"5": "views/customers.py",
"6": "cheque_inquiry.py",
"7": "admin_panel/templates/admin_panel/baseHesabdari.html",
"8": "django_db_models_deletion",
"9": "0051_auto_20260705_1356.py",
"10": "admin_panel/storeBK1.html",
"11": "ViewSmokeTests",
"12": "admin_panel/templates/admin_panel/customerAction.html",
"13": "User",
"14": "run_all",
"15": "admin_panel/admin.py",
"16": "stock_in",
"17": "admin_panel/templates/admin_panel/bedehi.html",
"18": "hesabdariCheckPassFail.html",
"19": "Reference guide",
"20": "normalize_mobile",
"21": "graphify",
"22": "hesabdariResidNashode.html",
"23": "0017_factoriteam_garantyesh.py",
"24": "scanner.py",
"25": "0002_auto_20220114_1616.py",
"26": "SMSResult",
"27": "Rip'n Fast. Fewer Tokens. Better Code.",
"28": "models/sms.py",
"29": "admin_panel/templates/admin_panel/baseStore.html",
"30": "views/finance.py",
"31": "Customer Modals UI",
"32": "views/factors.py",
"33": "0034_alter_article_sarresid.py",
"34": "views/sms.py",
"35": "0035_alter_article_sarresid.py",
"36": "django_db_models",
"37": "0010_auto_20220122_1546.py",
"38": "0040_customer_laghv_bazdid.py",
"39": "test_sms.py",
"40": "0048_alter_productinstore_options.py",
"41": "0032_article_paystatus.py",
"42": "0033_article_nahvepardakht.py",
"43": "make_customer",
"44": "sms0098.py",
"45": "0044_sarresid_desc.py",
"46": "models/__init__.py",
"47": "make_user",
"48": "GEMINI.md",
"49": "admin/migrations/__init__.py",
"50": "Customer Action Include",
"51": "New Customer Base Template",
"52": "Customer Feedback Partial",
"53": "Customer Factor Detail",
"54": "Customer Accounting Template",
"55": "Customer Factor List",
"56": "Customer Feedback Page",
"57": "commands/__init__.py",
"58": "management/__init__.py",
"59": "admin_panel/migrations/__init__.py",
"60": "Action List Template",
"61": "base.html",
"62": "Customer New Factor",
"63": "Customer New Factor V2",
"64": "Customer Satisfaction List",
"65": "Customer Satisfaction Partial",
"66": "Customer Check/Debt Management",
"67": "admin_panel/templates/admin_panel/customerBaseNew.html",
"68": "Customer List",
"69": "admin_panel/templates/admin_panel/entezarBK.html",
"70": "Financial Dashboard",
"71": "Accounting Main View",
"72": "admin_panel/templates/admin_panel/index.html",
"73": "New Inventory Operation",
"74": "Inventory Operations List",
"75": "Operations Backup Template",
"76": "Financial Report Template",
"77": "Debt Collection Dashboard",
"78": "Inventory Movement Report",
"79": "Customer Satisfaction Dashboard",
"80": "Check and Credit Report Template",
"81": "Satisfaction List Template",
"82": "Store Inventory Template",
"83": "Store Transfers Template",
"84": "Stores List Template",
"85": "toCurrency",
"86": "templatetags/__init__.py",
"87": "URL: newStore",
"88": "URL: process_transfer_action",
"89": "URL: rezayatEdit",
"90": "khatibi/__init__.py",
"91": "Extended Requirements File",
"92": "Requirements File",
"93": "django_shortcuts",
"94": "resolve_recipients",
"95": "factories.py",
"96": "write_tree",
"97": "TemplatesPageUITests",
"98": "reconciliation_tool.py",
"99": "ModelsPackageContractTests",
"100": "django_conf",
"101": "Command",
"102": "UrlContractTests",
"103": "customerIDFactorBackup.html",
"104": "system.py",
"105": "BehinSMSProvider",
"106": "test_customers.py",
"107": "tests/__init__.py",
"108": "Command",
"109": "Command",
"110": "0061_sms_timing_and_primary.py",
"111": "bale/providers.py",
"112": "os",
"113": "Command",
"114": "0058_seed_sms_templates.py",
"115": "test_factors.py",
"116": "Customer",
"117": "customerNewBackup.html",
"118": "0059_sms_flow_control.py",
"119": "0060_sms_permission_label.py",
"120": "datetime",
"121": "0008_storeroom_user.py",
"122": "0013_alter_factor_options.py",
"123": "0018_alter_factoriteam_product.py",
"124": "0020_auto_20220126_1532.py",
"125": "0022_article_typepy.py",
"126": "0025_action_actiontype.py",
"127": "0028_action_grade.py",
"128": "0030_auto_20220414_1047.py",
"129": "0036_sarresid.py",
"130": "0037_sarresid_sarresiddt.py",
"131": "0039_customer_last_bazdid_dt.py",
"132": "0041_auto_20240216_1607.py",
"133": "0047_alter_productinstore_movement_type.py",
"134": "0049_alter_productinstore_options.py",
"135": "0057_sms.py",
"136": "admin_panel/sms/__init__.py",
"137": "bale/__init__.py",
"138": "integrations/__init__.py",
"139": "integrations/sms/__init__.py"
}
+1
View File
@@ -0,0 +1 @@
{"0": "5ff8a72726a083db", "1": "219b01f43ec5f76d", "2": "edab4be6f8bf260c", "3": "70018840272ab80e", "4": "d36df3c85181f806", "5": "9eb1daeea9dda6c2", "6": "076144c85b2925b4", "7": "0928f3aa4e597117", "8": "0e080dc901d1f670", "9": "a00d0684d05c01a4", "10": "9701f1591d8fda6e", "11": "5290f7a7558a9c3e", "12": "e6a8282720eded0c", "13": "05119fe8608a0cbd", "14": "903ab35120e04b54", "15": "91b75fb079f9c02c", "16": "c12f70f2136b1a97", "17": "06959e26b0aedfb2", "18": "90146abe85d9419f", "19": "21f888d34b160f31", "20": "bc287e5355564fe6", "21": "0f632fc57e9feeb0", "22": "af89b22ae0662d6d", "23": "a800ecffc01fc9d5", "24": "955593b6e631a7a7", "25": "a19c211702401a5b", "26": "e72a0ec031902754", "27": "c9e7bf6ac46f263c", "28": "1a67e40fab1eeadd", "29": "3f3a4eb6e6b2f4d6", "30": "389c17cd386d28f2", "31": "03f8896e9b60ca96", "32": "40771cc7fa5ee930", "33": "c02bc9595eaedd36", "34": "d76e40111efda56a", "35": "7714d721fe893ef5", "36": "466b12915e08a4f0", "37": "2f1621907db6f86f", "38": "ef8221c5fdeee7f8", "39": "a890d0d5da72d758", "40": "bf60a8731033e467", "41": "d5c0f5f6714c9991", "42": "9948fb8b548535ed", "43": "61882165e32527ce", "44": "f1310e6117296d59", "45": "90e57f26f669372d", "46": "95f91e2eaf52002c", "47": "32e693b104a691c2", "48": "66ed76491a412a72", "49": "e67668b62ee6c62c", "50": "68aee39cd24db354", "51": "09b7297ea62919f2", "52": "054401d27ffaa4b2", "53": "dace81c1995fb321", "54": "19e301aeace95bb2", "55": "d6fd0cec4634a0b0", "56": "0230d355a6dabb54", "57": "5cebbb5528321795", "58": "536fe28d24c361f5", "59": "b08bdc2712cb0f12", "60": "6ce8444f504022cc", "61": "8021f52559633383", "62": "d8e34a3b3eb43ee0", "63": "056b8d6b5072289b", "64": "171c34c74a95481b", "65": "73e9eca6f2ca1c9b", "66": "dcc2230ae751c29a", "67": "2a617b932f5d92b2", "68": "2f5417a69eb1d15e", "69": "d55cb638532fdfeb", "70": "06cae4389208269f", "71": "e9c0a7be255e9b26", "72": "455b4ca39e55ecb0", "73": "8105c55538c34081", "74": "74304670b54c018f", "75": "a27f876eabb044b2", "76": "73f74dd7a40776d6", "77": "29d433d164b99f89", "78": "3f44aa48aa05d597", "79": "ca8a7efba0a3e579", "80": "5cc770af9d184d77", "81": "2d31fa723128b164", "82": "9d814c2520329846", "83": "f97d1a5c3a066598", "84": "14f84a6e4cd311d6", "85": "3a592d27b1f4ccc9", "86": "0bf6f9d0aeab4fd8", "87": "8b491bb3ff0649dd", "88": "638812cee15d1b9b", "89": "3dd16d68928a479c", "90": "72dad91d66d471ce", "91": "353a9d09eaae47ea", "92": "2d29260874184e4a", "93": "00d4d9fc79267971", "94": "8fedb8424b4f7d54", "95": "c7ce06d5e4e87224", "96": "9d46bbe4641a8543", "97": "68b25e60cbe5368c", "98": "8f5a6aa62c618b5f", "99": "33819123dbb4d454", "100": "e8d32595704b722a", "101": "e4fcae6cb365fba9", "102": "32ed40052fe2c3f8", "103": "8ff365aee1c1a767", "104": "c5743041b2101132", "105": "891ec2c014046f05", "106": "0c4707ef57c917a2", "107": "43bbf35b96423d46", "108": "88f880da5a61f9dc", "109": "dbfbf8557361f5ea", "110": "6e90b41fbd82c265", "111": "118bde95524551e0", "112": "b85898490162caed", "113": "d55977ec0e3ae213", "114": "df7ea8ce3fa52be3", "115": "460dadaff123e841", "116": "4faac8c35fa8b703", "117": "aea8fc3ff351253f", "118": "bda9be183e0f933e", "119": "63526d01888c4f22", "120": "329e844b7c9edf2d", "121": "37d2f4398ee065c5", "122": "49438bf9f08f11ff", "123": "b9687fc2d4953aed", "124": "6288b9f165b037f4", "125": "f97753a176d20dae", "126": "9cc7a507cfecd8fb", "127": "edb619bf5ac0fc49", "128": "8cb6b3db3aadb973", "129": "c9638aef2c5c82d8", "130": "c6b3f6e0a15a6a0b", "131": "52502a4c1ef75ce6", "132": "8d20e5c1bce8916d", "133": "b5ce3fce9192f8b8", "134": "6cef5bc37e710e62", "135": "0098e532f53735f2", "136": "b738c5aefe939cff", "137": "a9bf69b3d550abef", "138": "b039e1c69c8fb7b9", "139": "60029bf0948fc02a"}
+1
View File
@@ -0,0 +1 @@
.
+1
View File
@@ -0,0 +1 @@
{"output_tokens": 126792}
+414
View File
@@ -0,0 +1,414 @@
# Graph Report - khatibi (2026-10-10)
## Corpus Check
- 160 files · ~127,728 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 7 file(s) not represented in the graph (top: (none) 4, .env 2, .csv 1)
## Summary
- 1280 nodes · 2237 edges · 140 communities (47 shown, 93 thin omitted)
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 131 edges (avg confidence: 0.87)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `250cdbab`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- views/store.py
- project_tree.py
- django_db
- Customer ID Backup/New Factor Template
- views/customers.py
- cheque_inquiry.py
- admin_panel/templates/admin_panel/baseHesabdari.html
- django_db_models_deletion
- 0051_auto_20260705_1356.py
- admin_panel/storeBK1.html
- ViewSmokeTests
- admin_panel/templates/admin_panel/customerAction.html
- User
- run_all
- admin_panel/admin.py
- stock_in
- admin_panel/templates/admin_panel/bedehi.html
- hesabdariCheckPassFail.html
- Reference guide
- normalize_mobile
- graphify
- hesabdariResidNashode.html
- 0017_factoriteam_garantyesh.py
- scanner.py
- 0002_auto_20220114_1616.py
- SMSResult
- Rip'n Fast. Fewer Tokens. Better Code.
- models/sms.py
- admin_panel/templates/admin_panel/baseStore.html
- views/finance.py
- Customer Modals UI
- views/factors.py
- 0034_alter_article_sarresid.py
- views/sms.py
- 0035_alter_article_sarresid.py
- django_db_models
- 0010_auto_20220122_1546.py
- 0040_customer_laghv_bazdid.py
- test_sms.py
- 0048_alter_productinstore_options.py
- 0032_article_paystatus.py
- 0033_article_nahvepardakht.py
- make_customer
- sms0098.py
- 0044_sarresid_desc.py
- models/__init__.py
- make_user
- GEMINI.md
- Customer Action Include
- New Customer Base Template
- Customer Feedback Partial
- Customer Factor Detail
- Customer Accounting Template
- Customer Factor List
- Customer Feedback Page
- Action List Template
- base.html
- Customer New Factor
- Customer New Factor V2
- Customer Satisfaction List
- Customer Satisfaction Partial
- Customer Check/Debt Management
- admin_panel/templates/admin_panel/customerBaseNew.html
- Customer List
- Financial Dashboard
- Accounting Main View
- New Inventory Operation
- Inventory Operations List
- Operations Backup Template
- Financial Report Template
- Debt Collection Dashboard
- Inventory Movement Report
- Customer Satisfaction Dashboard
- Check and Credit Report Template
- Satisfaction List Template
- Store Inventory Template
- Store Transfers Template
- Stores List Template
- toCurrency
- Extended Requirements File
- Requirements File
- django_shortcuts
- resolve_recipients
- factories.py
- write_tree
- TemplatesPageUITests
- reconciliation_tool.py
- ModelsPackageContractTests
- django_conf
- Command
- UrlContractTests
- customerIDFactorBackup.html
- system.py
- BehinSMSProvider
- test_customers.py
- Command
- Command
- 0061_sms_timing_and_primary.py
- bale/providers.py
- Command
- 0058_seed_sms_templates.py
- Customer
- customerNewBackup.html
- 0059_sms_flow_control.py
- 0060_sms_permission_label.py
- 0008_storeroom_user.py
- 0013_alter_factor_options.py
- 0018_alter_factoriteam_product.py
- 0020_auto_20220126_1532.py
- 0022_article_typepy.py
- 0025_action_actiontype.py
- 0028_action_grade.py
- 0030_auto_20220414_1047.py
- 0036_sarresid.py
- 0037_sarresid_sarresiddt.py
- 0039_customer_last_bazdid_dt.py
- 0041_auto_20240216_1607.py
- 0047_alter_productinstore_movement_type.py
- 0049_alter_productinstore_options.py
- 0057_sms.py
## God Nodes (most connected - your core abstractions)
1. `make_customer()` - 37 edges
2. `make_user()` - 24 edges
3. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
4. `User` - 22 edges
5. `create_transfer_request()` - 22 edges
6. `enqueue_sms()` - 21 edges
7. `SMSResult` - 20 edges
8. `normalize_mobile()` - 20 edges
9. `SMSProvider` - 19 edges
10. `Reference guide` - 18 edges
## Surprising Connections (you probably didn't know these)
- `check_financial_discrepancy()` --uses--> `Customer` [INFERRED]
check_finance.py → admin/models.py
- `run_360_audit()` --uses--> `Customer` [INFERRED]
ghost_hunter.py → admin/models.py
- ``nest=` is a max, so it cannot tell a long function from a tangled one` --references--> `index()` [INFERRED]
ripwire - README.md → admin/views.py
- `VisitQueue` --uses--> `User` [INFERRED]
admin_panel/models/actions.py → admin/models.py
- `StoreTransferRequest` --uses--> `User` [INFERRED]
admin_panel/models/store.py → admin/models.py
## Import Cycles
- None detected.
## Hyperedges (group relationships)
- **Customer Satisfaction & Collection** — admin_panel_templates_admin_panel_report_rezayat, admin_panel_templates_admin_panel_report_debtors_no_vosol, admin_panel_customer_customernazarinc [EXTRACTED 0.90]
- **Customer Management Flow** — admin_panel_customer_base_new, admin_panel_customer_action_inc, admin_panel_customer_id_factor, admin_panel_customer_hesabdari [EXTRACTED 1.00]
- **Financial & Debt Management** — admin_panel_templates_admin_panel_financial_dashboard, admin_panel_templates_admin_panel_hesabdari [EXTRACTED 1.00]
- **Graphify Output Artifacts** — graphify-out_graph_json, graphify-out_wiki_index_md, graphify-out_graph_report_md [EXTRACTED 1.00]
- **Graphify Subcommands** — graphify_query, graphify_path, graphify_explain [EXTRACTED 1.00]
- **Inventory Management System** — admin_panel_templates_admin_panel_store, admin_panel_templates_admin_panel_store_transfers, admin_panel_templates_admin_panel_report_inventory_movements, admin_panel_templates_admin_panel_operationsbk [EXTRACTED 1.00]
- **Bedehi Report Navigation** — django_urls_admin_panel_bedehi, django_urls_admin_panel_bedehikhadamat, django_urls_admin_panel_bedehinasb [EXTRACTED]
- **Login Form** — admin_panel_templates_admin_panel_login, django_url_admin_panel_p_login, django_csrf_token, form_field_username, form_field_password [EXTRACTED]
- **New Store Form** — admin_panel_templates_admin_panel_storebk1, django_url_admin_panel_newstore, django_csrf_token, form_field_storename [EXTRACTED]
- **Store Inventory Display** — admin_panel_templates_admin_panel_storebk1, template_variable_productsum, product_property_product_name, product_property_mojodi [EXTRACTED]
- **Customer List Page** — admin_panel_templates_admin_panel_entezar_html, concept_customer_data, concept_url_admin_panel_customers [INFERRED 0.80]
- **Customer Lifecycle Management** — admin_panel_templates_admin_panel_customers [INFERRED 0.85]
- **Customer Interaction Lifecycle** — admin_panel_templates_admin_panel_customers, admin_panel_templates_admin_panel_customerNew, admin_panel_templates_admin_panel_customerRezayat [INFERRED 0.85]
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customernewbackup_html, admin_panel_urls_newfactor, concept_client_side_form_logic, concept_customer, concept_category [INFERRED 0.85]
- **Customer Management UI Flow** — admin_panel_templates_admin_panel_customersbk_html, admin_panel_templates_admin_panel_customersbk_html_createcs, admin_panel_templates_admin_panel_customersbk_html_openrow, concept_customer_modal_ui [INFERRED 0.90]
- **Financial & Debt Management Flow** — admin_panel_templates_admin_panel_customerSarresid, admin_panel_templates_admin_panel_hesabdari, admin_panel_templates_admin_panel_financial_dashboard [INFERRED 0.90]
- **New Factor Item Creation Flow** — admin_panel_templates_admin_panel_customeridfactorbackup_html_js, panel_urls_productmojodi, concept_product, concept_productcategory [INFERRED 0.90]
- **Settle Due Payment Flow** — admin_panel_templates_admin_panel_hesabdaricheckpassfail_html, admin_panel_url_passcheck, admin_panel_url_pardakhtnesiye [INFERRED 0.90]
- **Accounting and Billing Flow** — admin_panel_templates_admin_panel_basehesabdari, concept_url_admin_panel_hesabdari, concept_url_admin_panel_bedehi [INFERRED]
- **Bedehi Template Data Contract** — admin_panel_templates_admin_panel_bedehi_html, django_context_variable_mandehesabkp, django_context_variable_articls [INFERRED]
- **Customer Action Creation Flow** — admin_panel_templates_admin_panel_customeraction, admin_panel_urls_customernewaction, concept_action_model [INFERRED]
- **Customer Management UI** — admin_panel_templates_admin_panel_customeridbackup_html, concept_customer, django_url_admin_panel_customeredit, api_endpoint_panel_customer_del, django_url_admin_panel_customerfactors [INFERRED]
- **Client-side Payment Processing Flow** — admin_panel_templates_admin_panel_hesabdaricheckpass_html, admin_panel_urls_passcheck, admin_panel_urls_pardakhtnesiye [INFERRED]
- **Nesiye Payment Flow** — admin_panel_templates_admin_panel_hesabdariresidnashode, admin_panel_urls_pardakhtnesiye [INFERRED]
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
## Communities (140 total, 93 thin omitted)
### Community 0 - "views/store.py"
Cohesion: 0.07
Nodes (26): StoreTransferRequest, Meta, Status, approve_transfer_request(), cancel_transfer_request(), _check_source_store_permission(), _check_target_store_permission(), create_transfer_request() (+18 more)
### Community 1 - "project_tree.py"
Cohesion: 0.21
Nodes (6): is_excluded_file(), main(), non_negative_integer(), parse_args(), sorted_entries(), warn()
### Community 3 - "django_db"
Cohesion: 0.07
Nodes (14): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+6 more)
### Community 4 - "Customer ID Backup/New Factor Template"
Cohesion: 0.15
Nodes (4): Customer ID Backup/New Factor Template, all_products (JS variable), buildOrderList (JS function), orderList (JS variable)
### Community 5 - "views/customers.py"
Cohesion: 0.13
Nodes (16): customer_toggle_map(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors(), customerHesabdari(), customerHesabdariAdd() (+8 more)
### Community 6 - "cheque_inquiry.py"
Cohesion: 0.17
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
### Community 7 - "admin_panel/templates/admin_panel/baseHesabdari.html"
Cohesion: 0.13
Nodes (14): URL: admin_panel:ActionListUserToday, URL: admin_panel:bedehi, URL: admin_panel:entezar, URL: admin_panel:hesabdari, URL: admin_panel:hesabdari_residnashone, URL: admin_panel:index, URL: admin_panel:p_logout, URL: admin_panel:rezayat (+6 more)
### Community 8 - "django_db_models_deletion"
Cohesion: 0.06
Nodes (15): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+7 more)
### Community 10 - "admin_panel/storeBK1.html"
Cohesion: 0.11
Nodes (17): Django CSRF Token, Django Custom Tags, Django Static Files, Django URL: admin_panel:newStore, Django URL: admin_panel:operations, Django URL: admin_panel:p_login, Django URL: admin_panel:store, Form Field: password (+9 more)
### Community 12 - "admin_panel/templates/admin_panel/customerAction.html"
Cohesion: 0.13
Nodes (13): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNew, admin_panel:customerNewAction URL, Action Data Model, Articl Model, Customer Model, DataTables Library, jQuery (+5 more)
### Community 13 - "User"
Cohesion: 0.07
Nodes (30): User, Action, VisitQueue, Factor, Meta, FactorIteam, Product, Meta (+22 more)
### Community 14 - "run_all"
Cohesion: 0.05
Nodes (9): run_all(), activate_sms(), ColdStartTests, ConsoleEncodingTests, HttpProviderRobustnessTests, NormalizeTests, ScannerTests, WorkerTests (+1 more)
### Community 15 - "admin_panel/admin.py"
Cohesion: 0.09
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
### Community 16 - "stock_in"
Cohesion: 0.11
Nodes (11): Customer, Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, Command (+3 more)
### Community 17 - "admin_panel/templates/admin_panel/bedehi.html"
Cohesion: 0.12
Nodes (11): admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, URL: admin_panel:newFactor, mandehesabKP, admin_panel.ViewReportMali, toJalali, admin_panel:bedehi (+3 more)
### Community 18 - "hesabdariCheckPassFail.html"
Cohesion: 0.20
Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin_panel:customerNew URL, admin_panel:pardakhtNesiye URL, /panel/passCheck/ URL, admin_panel.ViewReportMali Permission, jQuery JS Library (+1 more)
### Community 19 - "Reference guide"
Cohesion: 0.07
Nodes (27): 10. Quality evaluation, 11. Agent integration, 12. Supported languages and formats, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License (+19 more)
### Community 20 - "normalize_mobile"
Cohesion: 0.09
Nodes (12): customer_phone_entries(), _iter_sources(), normalize_primary_values(), PhoneEntry, primary_phones(), resolved_recipients(), save_primary_normalized(), stored_primary_normalized() (+4 more)
### Community 21 - "graphify"
Cohesion: 0.22
Nodes (8): Copilot Chat, graphify, graphify-out/graph.json, graphify-out/GRAPH_REPORT.md, graphify-out/wiki/index.md, graphify explain, graphify path, graphify query
### Community 22 - "hesabdariResidNashode.html"
Cohesion: 0.47
Nodes (5): jQuery, pDatepicker JS Library, custom_tags, toCurrency, toJalali
### Community 24 - "scanner.py"
Cohesion: 0.13
Nodes (12): enqueue_sms(), _aware(), _Budget, scan_cheque_due(), scan_cheque_registration(), scan_debt_due(), scan_nesiye_due(), scan_survey() (+4 more)
### Community 25 - "0002_auto_20220114_1616.py"
Cohesion: 0.29
Nodes (3): Migration, Migration, Migration
### Community 26 - "SMSResult"
Cohesion: 0.13
Nodes (8): SMSDeliveryResult, SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
### Community 27 - "Rip'n Fast. Fewer Tokens. Better Code."
Cohesion: 0.05
Nodes (42): index(), 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines (+34 more)
### Community 28 - "models/sms.py"
Cohesion: 0.08
Nodes (12): CustomerSMSSetting, Meta, SMSLog, Meta, SMSProviderConfig, Meta, SMSQueue, Meta (+4 more)
### Community 29 - "admin_panel/templates/admin_panel/baseStore.html"
Cohesion: 0.13
Nodes (12): admin_panel/base.html, Customer Data Structure, Django Custom Tags: custom_tags, kamaDatepicker Library, URL: admin_panel:customers, URL: admin_panel:operations, URL: admin_panel:productAdd, URL: admin_panel:store (+4 more)
### Community 30 - "views/finance.py"
Cohesion: 0.20
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
### Community 31 - "Customer Modals UI"
Cohesion: 0.67
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
### Community 32 - "views/factors.py"
Cohesion: 0.16
Nodes (6): FactorDel(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp(), _safe_int()
### Community 34 - "views/sms.py"
Cohesion: 0.17
Nodes (12): parse_scan_since(), resolve_recipients_test(), _sample_context(), sms_customer_toggle(), sms_primary_toggle(), sms_queue(), sms_queue_retry(), sms_send_custom() (+4 more)
### Community 36 - "django_db_models"
Cohesion: 0.15
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 39 - "test_sms.py"
Cohesion: 0.14
Nodes (4): apply_timing(), current_timing_value(), timing_for(), TimingsTests
### Community 43 - "make_customer"
Cohesion: 0.15
Nodes (6): PhoneNumber, make_customer(), PanelTestCase, PrimaryNumberPanelTests, RuleTests, WelcomeTriggerTests
### Community 44 - "sms0098.py"
Cohesion: 0.15
Nodes (5): _clean(), to_local(), _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
### Community 46 - "models/__init__.py"
Cohesion: 0.15
Nodes (5): format_amount(), format_jalali(), render_template(), _SafeDict, is_sms_enabled()
### Community 93 - "django_shortcuts"
Cohesion: 0.13
Nodes (3): Migration, ArticleType, Sarresid
### Community 94 - "resolve_recipients"
Cohesion: 0.20
Nodes (3): resolve_recipients(), mark_primary(), RecipientTests
### Community 95 - "factories.py"
Cohesion: 0.27
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
### Community 96 - "write_tree"
Cohesion: 0.21
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
### Community 98 - "reconciliation_tool.py"
Cohesion: 0.19
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 99 - "ModelsPackageContractTests"
Cohesion: 0.05
Nodes (15): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection(), entrypoint.sh script, main(), run.sh script, start_service() (+7 more)
### Community 100 - "django_conf"
Cohesion: 0.22
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
### Community 103 - "customerIDFactorBackup.html"
Cohesion: 0.18
Nodes (8): Client-side script in customerIDFactorBackup.html, URL: admin_panel:customerEdit, Customer Data, Factor Data Model, Product Data Model, Product Category Data Model, User Data Model, URL: /panel/productMojodi/
### Community 110 - "0061_sms_timing_and_primary.py"
Cohesion: 0.22
Nodes (4): backfill_primary_phones(), Migration, _normalize(), normalize_template_timing()
### Community 111 - "bale/providers.py"
Cohesion: 0.31
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
### Community 116 - "Customer"
Cohesion: 0.29
Nodes (3): Customer, Meta, PhoneNumber
### Community 117 - "customerNewBackup.html"
Cohesion: 0.29
Nodes (6): admin_panel:newFactor URL, Category Data, Client-side Form Logic, CSRF Token, custom_tags, static
## Ambiguous Edges - Review These
- `admin_panel/base.html` → `admin_panel/templates/admin_panel/baseHesabdari.html` [AMBIGUOUS]
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
## Knowledge Gaps
- **110 isolated node(s):** `DataTables Library`, `Customer Data Object`, `Factor Data Object`, `admin_panel:pardakhtNesiye URL`, `/panel/passCheck/ URL` (+105 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 575 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **93 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `SMSProviderConfig` connect `models/sms.py` to `scanner.py`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `DataTables Library`, `Customer Data Object`, `Factor Data Object` to the rest of the system?**
_110 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `views/store.py` be split into smaller, more focused modules?**
_Cohesion score 0.06540825285338016 - nodes in this community are weakly interconnected._
- **Why does `Languages` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `scanner.py`?**
_High betweenness centrality (0.026) - this node is a cross-community bridge._
- **Should `django_db` be split into smaller, more focused modules?**
_Cohesion score 0.06896551724137931 - nodes in this community are weakly interconnected._
### Low-confidence Hints
_AMBIGUOUS edges — the extractor was unsure. Verify before acting on these._
- **What is the exact relationship between `admin_panel/base.html` and `admin_panel/templates/admin_panel/baseHesabdari.html`?**
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+360
View File
@@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>graphify - graphify-out/graph.html</title>
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
crossorigin="anonymous"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
#graph { flex: 1; }
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
#search:focus { border-color: #4E79A7; }
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-item:hover { background: #2a2a4e; }
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
#info-content .field { margin-bottom: 5px; }
#info-content .field b { color: #e0e0e0; }
#info-content .empty { color: #555; font-style: italic; }
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
.neighbor-link:hover { background: #2a2a4e; }
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
.legend-item.dimmed { opacity: 0.35; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend-count { color: #666; font-size: 11px; }
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
#legend-controls label:hover { color: #e0e0e0; }
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
</style>
</head>
<body>
<div id="graph"></div>
<div id="sidebar">
<div id="search-wrap">
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
<div id="search-results"></div>
</div>
<div id="info-panel">
<h3>Node Info</h3>
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
</div>
<div id="legend-wrap">
<h3>Communities</h3>
<div id="legend-controls">
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
</div>
<div id="legend"></div>
</div>
<div id="stats">1280 nodes &middot; 2237 edges &middot; 140 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "admin_panel_models_store_storetransferrequest", "label": "StoreTransferRequest", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "StoreTransferRequest", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 10}, {"id": "admin_panel_models_store_storetransferrequest_meta", "label": "Meta", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Meta", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_models_store_storetransferrequest_status", "label": "Status", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Status", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "tests_test_inventory_productinstoremathtests", "label": "ProductInStoreMathTests", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 13.4, "font": {"size": 0, "color": "#ffffff"}, "title": "ProductInStoreMathTests", "community": 0, "community_name": "views/store.py", "source_file": "tests/test_inventory.py", "file_type": "code", "degree": 9}, {"id": "tests_test_inventory_transferworkflowtests", "label": "TransferWorkflowTests", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 14.9, "font": {"size": 12, "color": "#ffffff"}, "title": "TransferWorkflowTests", "community": 0, "community_name": "views/store.py", "source_file": "tests/test_inventory.py", "file_type": "code", "degree": 13}, {"id": "integrations_announcements_announcementrejected", "label": "AnnouncementRejected", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 11.5, "font": {"size": 0, "color": "#ffffff"}, "title": "AnnouncementRejected", "community": 100, "community_name": "django_conf", "source_file": "integrations/announcements.py", "file_type": "code", "degree": 4}, {"id": "integrations_voip_voipcallresult", "label": "VoipCallResult", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.8, "font": {"size": 0, "color": "#ffffff"}, "title": "VoipCallResult", "community": 100, "community_name": "django_conf", "source_file": "integrations/voip.py", "file_type": "code", "degree": 2}, {"id": "admin_panel_management_commands_sms_worker_command", "label": "Command", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "Command", "community": 101, "community_name": "Command", "source_file": "admin_panel/management/commands/sms_worker.py", "file_type": "code", "degree": 10}, {"id": "tests_test_urls_contract_urlcontracttests", "label": "UrlContractTests", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 12.3, "font": {"size": 0, "color": "#ffffff"}, "title": "UrlContractTests", "community": 102, "community_name": "UrlContractTests", "source_file": "tests/test_urls_contract.py", "file_type": "code", "degree": 6}, {"id": "admin_panel_models_system_cachetable", "label": "CacheTable", "color": {"background": "#59A14F", "border": "#59A14F", "highlight": {"background": "#ffffff", "border": "#59A14F"}}, "size": 10.8, "font": {"size": 0, "color": "#ffffff"}, "title": "CacheTable", "community": 104, "community_name": "system.py", "source_file": "admin_panel/models/system.py", "file_type": "code", "degree": 2}, {"id": "integrations_sms_behin_behinsmsprovider", "label": "BehinSMSProvider", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "BehinSMSProvider", "community": 105, "community_name": "BehinSMSProvider", "source_file": "integrations/sms/behin.py", "file_type": "code", "degree": 10}, {"id": "tests_test_customers_customercrudtests", "label": "CustomerCrudTests", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 12.7, "font": {"size": 0, "color": "#ffffff"}, "title": "CustomerCrudTests", "community": 106, "community_name": "test_customers.py", "source_file": "tests/test_customers.py", "file_type": "code", "degreLine truncated
const RAW_EDGES = [{"from": "admin_panel_services_approve_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_cancel_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_create_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_reject_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store", "to": "admin_panel_models_store_storetransferrequest", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_meta", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_status", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_str", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_clean", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_models_user", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_inventory", "to": "tests_test_inventory_productinstoremathtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_py_testcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_setup", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_initial_in_records_history_and_stock", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_multiple_movements_accumulate_correctly", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_editing_a_row_updates_stock_and_history", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_deleting_a_row_creates_history_and_reduces_stock", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_garbage_numeric_values_are_coerced_to_zero", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "admin_models_productinstore", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_inventory", "to": "tests_test_inventory_transferworkflowtests", "label": "contains", "tLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/store.py", "count": 68}, {"cid": 1, "color": "#F28E2B", "label": "project_tree.py", "count": 14}, {"cid": 2, "color": "#E15759", "label": "sqlite_db_backup.py", "count": 11}, {"cid": 3, "color": "#76B7B2", "label": "django_db", "count": 29}, {"cid": 4, "color": "#59A14F", "label": "Customer ID Backup/New Factor Template", "count": 14}, {"cid": 5, "color": "#EDC948", "label": "views/customers.py", "count": 26}, {"cid": 6, "color": "#B07AA1", "label": "cheque_inquiry.py", "count": 21}, {"cid": 7, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 8, "color": "#9C755F", "label": "django_db_models_deletion", "count": 31}, {"cid": 9, "color": "#BAB0AC", "label": "0051_auto_20260705_1356.py", "count": 2}, {"cid": 10, "color": "#4E79A7", "label": "admin_panel/storeBK1.html", "count": 19}, {"cid": 11, "color": "#F28E2B", "label": "ViewSmokeTests", "count": 8}, {"cid": 12, "color": "#E15759", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 17}, {"cid": 13, "color": "#76B7B2", "label": "User", "count": 48}, {"cid": 14, "color": "#59A14F", "label": "run_all", "count": 64}, {"cid": 15, "color": "#EDC948", "label": "admin_panel/admin.py", "count": 28}, {"cid": 16, "color": "#B07AA1", "label": "stock_in", "count": 24}, {"cid": 17, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 20}, {"cid": 18, "color": "#9C755F", "label": "hesabdariCheckPassFail.html", "count": 10}, {"cid": 19, "color": "#BAB0AC", "label": "Reference guide", "count": 27}, {"cid": 20, "color": "#4E79A7", "label": "normalize_mobile", "count": 34}, {"cid": 21, "color": "#F28E2B", "label": "graphify", "count": 9}, {"cid": 22, "color": "#E15759", "label": "hesabdariResidNashode.html", "count": 6}, {"cid": 23, "color": "#76B7B2", "label": "0017_factoriteam_garantyesh.py", "count": 2}, {"cid": 24, "color": "#59A14F", "label": "scanner.py", "count": 32}, {"cid": 25, "color": "#EDC948", "label": "0002_auto_20220114_1616.py", "count": 7}, {"cid": 26, "color": "#B07AA1", "label": "SMSResult", "count": 30}, {"cid": 27, "color": "#FF9DA7", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 44}, {"cid": 28, "color": "#9C755F", "label": "models/sms.py", "count": 26}, {"cid": 29, "color": "#BAB0AC", "label": "admin_panel/templates/admin_panel/baseStore.html", "count": 15}, {"cid": 30, "color": "#4E79A7", "label": "views/finance.py", "count": 24}, {"cid": 31, "color": "#F28E2B", "label": "Customer Modals UI", "count": 3}, {"cid": 32, "color": "#E15759", "label": "views/factors.py", "count": 22}, {"cid": 33, "color": "#76B7B2", "label": "0034_alter_article_sarresid.py", "count": 2}, {"cid": 34, "color": "#59A14F", "label": "views/sms.py", "count": 22}, {"cid": 35, "color": "#EDC948", "label": "0035_alter_article_sarresid.py", "count": 2}, {"cid": 36, "color": "#B07AA1", "label": "django_db_models", "count": 20}, {"cid": 37, "color": "#FF9DA7", "label": "0010_auto_20220122_1546.py", "count": 2}, {"cid": 38, "color": "#9C755F", "label": "0040_customer_laghv_bazdid.py", "count": 2}, {"cid": 39, "color": "#BAB0AC", "label": "test_sms.py", "count": 20}, {"cid": 40, "color": "#4E79A7", "label": "0048_alter_productinstore_options.py", "count": 2}, {"cid": 41, "color": "#F28E2B", "label": "0032_article_paystatus.py", "count": 2}, {"cid": 42, "color": "#E15759", "label": "0033_article_nahvepardakht.py", "count": 2}, {"cid": 43, "color": "#76B7B2", "label": "make_customer", "count": 19}, {"cid": 44, "color": "#59A14F", "label": "sms0098.py", "count": 19}, {"cid": 45, "color": "#EDC948", "label": "0044_sarresid_desc.py", "count": 2}, {"cid": 46, "color": "#B07AA1", "label": "models/__init__.py", "count": 18}, {"cid": 47, "color": "#FF9DA7", "label": "make_user", "count": 17}, {"cid": 48, "color": "#9C755F", "label": "GEMINI.md", "count": 2}, {"cid": 49, "color": "#BAB0AC", "label": "admin/migrations/__init__.py", "count": 1}, {"cid": 50, "color": "#4E79A7", "label": "Customer Action Include", "count": 1}, {"cid": 51, "color": "#F28E2B", "label": "New Customer Base Template", "count": 1}, {"cid": 52, "color": "#E15759", "label": "Customer Feedback Partial", "count": 1}, {"cid": 53, "color": "#76B7B2", "label": "Customer Factor Detail", "count": 1}, {"cid": 54, "color": "#59A14F", "label": "Customer Accounting Template", "count": 1}, {"cid": 55, "color": "#EDC948", "label": "Customer Factor List", "count": 1}, {"cid": 56, "color": "#B07AA1", "label": "Customer Feedback Page", "count": 1}, {"cid": 57, "color": "#FF9DA7", "label": "commands/__init__.py", "count": 1}, {"cid": 58, "color": "#9C755F", "label": "management/__init__.py", "count": 1}, {"cid": 59, "color": "#BAB0AC", "label": "admin_panel/migrations/__init__.py", "count": 1}, {"cid": 60, "color": "#4E79A7", "label": "Action List Template", "count": 1}, {"cid": 61, "color": "#F28E2B", "label": "base.html", "count": 1}, {"cid": 62,Line truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// Build vis datasets
const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
// Fermat/golden-angle spiral seed positions (#3699): spreading nodes out
// before physics runs keeps avoidOverlap from computing near-zero-distance
// repulsion that blows the BarnesHut recursion into a stack overflow on large
// graphs. `i` is the map index — physics still settles small graphs identically.
x: 30 * Math.sqrt(i) * Math.cos(i * 2.4),
y: 30 * Math.sqrt(i) * Math.sin(i * 2.4),
community: n.community, community_name: n.community_name,
source_file: n.source_file, file_type: n.file_type, degree: n.degree,
member_count: n.member_count,
})));
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({
id: i, from: e.from, to: e.to,
label: '',
title: e.title,
dashes: e.dashes,
width: e.width,
color: e.color,
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
})));
const container = document.getElementById('graph');
const network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, {
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -60,
centralGravity: 0.005,
springLength: 120,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0.8,
},
stabilization: { iterations: 200, fit: true },
},
interaction: {
hover: true,
tooltipDelay: 100,
hideEdgesOnDrag: true,
navigationButtons: false,
keyboard: false,
},
nodes: { shape: 'dot', borderWidth: 1.5 },
edges: { smooth: { type: 'continuous', roundness: 0.2 }, selectionWidth: 3 },
});
network.once('stabilizationIterationsDone', () => {
network.setOptions({ physics: { enabled: false } });
});
function showInfo(nodeId) {
const n = nodesDS.get(nodeId);
if (!n) return;
const neighborIds = network.getConnectedNodes(nodeId);
const neighborItems = neighborIds.map(nid => {
const nb = nodesDS.get(nid);
const color = nb ? nb.color.background : '#555';
return `<span class="neighbor-link" style="border-left-color:${esc(color)}" data-nid="${esc(nid)}">${esc(nb ? nb.label : nid)}</span>`;
}).join('');
const isMetaNode = !n.file_type && !n.source_file;
let html = `<div class="field"><b>${esc(n.label)}</b></div>`;
if (!isMetaNode) {
html += `<div class="field">Type: ${esc(n.file_type || 'unknown')}</div>`;
}
html += `<div class="field">Community: ${esc(n.community_name || '')}</div>`;
if (!isMetaNode) {
html += `<div class="field">Source: ${esc(n.source_file || '-')}</div>`;
} else if (n.member_count !== undefined && n.member_count !== null) {
html += `<div class="field">Members: ${esc(n.member_count)}</div>`;
}
html += `<div class="field">Degree: ${n.degree}</div>`;
if (neighborIds.length) {
html += `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${neighborIds.length})</div><div id="neighbors-list">${neighborItems}</div>`;
}
document.getElementById('info-content').innerHTML = html;
}
function focusNode(nodeId) {
network.focus(nodeId, { scale: 1.4, animation: true });
network.selectNodes([nodeId]);
showInfo(nodeId);
}
// Neighbor links use a data attribute + one delegated listener rather than an
// inline onclick. A node id/label sourced from a document or a scraped URL
// (graphify add) can contain a double-quote; dropping the stringified id
// unescaped into a quoted onclick both broke every link and allowed a hostile
// source to inject an event handler into the local report (stored XSS, #1838).
// esc() on data-nid keeps the value inside the attribute; the listener reads it
// back verbatim. Bound to document so it survives the innerHTML rebuild that
// recreates #neighbors-list on each showInfo().
document.addEventListener('click', e => {
const el = e.target.closest('.neighbor-link');
if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid);
});
// Track hovered node — hover detection is more reliable than click params
let hoveredNodeId = null;
network.on('hoverNode', params => {
hoveredNodeId = params.node;
container.style.cursor = 'pointer';
});
network.on('blurNode', () => {
hoveredNodeId = null;
container.style.cursor = 'default';
});
container.addEventListener('click', () => {
if (hoveredNodeId !== null) {
showInfo(hoveredNodeId);
network.selectNodes([hoveredNodeId]);
}
});
network.on('click', params => {
if (params.nodes.length > 0) {
showInfo(params.nodes[0]);
} else if (hoveredNodeId === null) {
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
}
});
const searchInput = document.getElementById('search');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
searchResults.innerHTML = '';
if (!q) { searchResults.style.display = 'none'; return; }
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
if (!matches.length) { searchResults.style.display = 'none'; return; }
searchResults.style.display = 'block';
matches.forEach(n => {
const el = document.createElement('div');
el.className = 'search-item';
el.textContent = n.label;
el.style.borderLeft = `3px solid ${n.color.background}`;
el.style.paddingLeft = '8px';
el.onclick = () => {
network.focus(n.id, { scale: 1.5, animation: true });
network.selectNodes([n.id]);
showInfo(n.id);
searchResults.style.display = 'none';
searchInput.value = '';
};
searchResults.appendChild(el);
});
});
document.addEventListener('click', e => {
if (!searchResults.contains(e.target) && e.target !== searchInput)
searchResults.style.display = 'none';
});
const hiddenCommunities = new Set();
const selectAllCb = document.getElementById('select-all-cb');
function updateSelectAllState() {
const total = LEGEND.length;
const hidden = hiddenCommunities.size;
selectAllCb.checked = hidden === 0;
selectAllCb.indeterminate = hidden > 0 && hidden < total;
}
function toggleAllCommunities(hide) {
document.querySelectorAll('.legend-item').forEach(item => {
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
});
document.querySelectorAll('.legend-cb').forEach(cb => {
cb.checked = !hide;
});
LEGEND.forEach(c => {
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
});
const updates = RAW_NODES.map(n => ({ id: n.id, hidden: hide }));
nodesDS.update(updates);
updateSelectAllState();
}
const legendEl = document.getElementById('legend');
LEGEND.forEach(c => {
const item = document.createElement('div');
item.className = 'legend-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'legend-cb';
cb.checked = true;
cb.addEventListener('change', (e) => {
e.stopPropagation();
if (cb.checked) {
hiddenCommunities.delete(c.cid);
item.classList.remove('dimmed');
} else {
hiddenCommunities.add(c.cid);
item.classList.add('dimmed');
}
const updates = RAW_NODES
.filter(n => n.community === c.cid)
.map(n => ({ id: n.id, hidden: !cb.checked }));
nodesDS.update(updates);
updateSelectAllState();
});
item.innerHTML = `<div class="legend-dot" style="background:${c.color}"></div>
<span class="legend-label">${c.label}</span>
<span class="legend-count">${c.count}</span>`;
item.prepend(cb);
item.onclick = (e) => {
if (e.target === cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
};
legendEl.appendChild(item);
});
</script>
<script>
// Render hyperedges as shaded regions
const hyperedges = [{"id": "customer_retention_flow", "label": "Customer Satisfaction & Collection", "nodes": ["admin_panel_templates_admin_panel_report_rezayat", "admin_panel_templates_admin_panel_report_debtors_no_vosol", "admin_panel_customer_customernazarinc"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 0.9}, {"id": "customer_management_flow", "label": "Customer Management Flow", "nodes": ["admin_panel_customer_base_new", "admin_panel_customer_action_inc", "admin_panel_customer_id_factor", "admin_panel_customer_hesabdari"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 1.0}, {"id": "financial_management_flow", "label": "Financial & Debt Management", "nodes": ["admin_panel_templates_admin_panel_financial_dashboard", "admin_panel_templates_admin_panel_hesabdari"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 1.0}, {"id": "graphify_output_artifacts", "label": "Graphify Output Artifacts", "nodes": ["graphify-out_graph_json", "graphify-out_wiki_index_md", "graphify-out_graph_report_md"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".github/copilot-instructions.md"}, {"id": "graphify_subcommands", "label": "Graphify Subcommands", "nodes": ["graphify_query", "graphify_path", "graphify_explain"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".github/copilot-instructions.md"}, {"id": "inventory_management_flow", "label": "Inventory Management System", "nodes": ["admin_panel_templates_admin_panel_store", "admin_panel_templates_admin_panel_store_transfers", "admin_panel_templates_admin_panel_report_inventory_movements", "admin_panel_templates_admin_panel_operationsbk"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 1.0}, {"id": "bedehi_report_navigation", "label": "Bedehi Report Navigation", "nodes": ["django_urls_admin_panel_bedehi", "django_urls_admin_panel_bedehikhadamat", "django_urls_admin_panel_bedehinasb"], "relation": "participate_in", "confidence": "EXTRACTED", "source_file": "admin_panel/templates/admin_panel/bedehi.html"}, {"id": "login_form", "label": "Login Form", "nodes": ["admin_panel_templates_admin_panel_login", "django_url_admin_panel_p_login", "django_csrf_token", "form_field_username", "form_field_password"], "relation": "form", "confidence": "EXTRACTED", "source_file": "admin_panel/templates/admin_panel/login.html"}, {"id": "new_store_form", "label": "New Store Form", "nodes": ["admin_panel_templates_admin_panel_storebk1", "django_url_admin_panel_newstore", "django_csrf_token", "form_field_storename"], "relation": "form", "confidence": "EXTRACTED", "source_file": "admin_panel/templates/admin_panel/storeBK1.html"}, {"id": "store_inventory_display", "label": "Store Inventory Display", "nodes": ["admin_panel_templates_admin_panel_storebk1", "template_variable_productsum", "product_property_product_name", "product_property_mojodi"], "relation": "participate_in", "confidence": "EXTRACTED", "source_file": "admin_panel/templates/admin_panel/storeBK1.html"}, {"id": "customer_list_page", "label": "Customer List Page", "nodes": ["admin_panel_templates_admin_panel_entezar_html", "concept_customer_data", "concept_url_admin_panel_customers"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "admin_panel/templates/admin_panel/entezar.html"}, {"id": "customer_interaction_flow", "label": "Customer Lifecycle Management", "nodes": ["admin_panel_templates_admin_panel_customers"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.85}, {"id": "customer_lifecycle_management", "label": "Customer Interaction Lifecycle", "nodes": ["admin_panel_templates_admin_panel_customers", "admin_panel_templates_admin_panel_customerNew", "admin_panel_templates_admin_panel_customerRezayat"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "admin_panel/templates/admin_panel/customers.html"}, {"id": "new_factor_creation_flow", "label": "New Factor Creation Flow", "nodes": ["admin_panel_templates_admin_panel_customernewbackup_html", "admin_panel_urls_newfactor", "concept_client_side_form_logic", "concept_customer", "concept_category"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "admin_panel/templates/admin_panel/customerNewBackup.html"}, {"id": "customer_management_ui_flow", "label": "Customer Management UI Flow", "nodes": ["admin_panel_templates_admin_panel_customersbk_html", "admin_panel_templates_admin_panel_customersbk_html_createcs", "admin_panel_templates_admin_panel_customersbk_html_openrow", "concept_customer_modal_ui"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.9, "source_file": "admin_panel/templates/admin_panel/customersBK.html"}, {"id": "financial_management_flow", "label": "Financial & Debt Management Flow", "nodes": ["adLine truncated
// afterDrawing passes ctx already transformed to network coordinate space.
// Draw node positions raw — no manual pan/zoom/DPR math needed.
// Andrew's monotone chain. Returns the hull in counter-clockwise order, which
// is what the perimeter must be traced in. Collinear and duplicate points
// collapse to the extremes, so degenerate member sets render as a segment
// rather than a zero-area crossed path.
function convexHull(pts) {
const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
if (p.length < 3) return p;
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const build = seq => {
const out = [];
for (const q of seq) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
out.push(q);
}
out.pop();
return out;
};
const hull = build(p).concat(build(p.slice().reverse()));
return hull.length >= 3 ? hull : p;
}
network.on('afterDrawing', function(ctx) {
hyperedges.forEach(h => {
const positions = h.nodes
.map(nid => network.getPositions([nid])[nid])
.filter(p => p !== undefined);
if (positions.length < 2) return;
ctx.save();
ctx.globalAlpha = 0.12;
ctx.fillStyle = '#6366f1';
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.beginPath();
// Centroid and expanded hull in network coordinates.
// The perimeter must follow hull order, not h.nodes order: tracing the
// raw member order self-intersects whenever the layout does not happen
// to place members in angular order, filling as crossed wedges.
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
const hull = convexHull(positions);
const expanded = hull.map(p => ({
x: cx + (p.x - cx) * 1.15,
y: cy + (p.y - cy) * 1.15
}));
ctx.moveTo(expanded[0].x, expanded[0].y);
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 0.4;
ctx.stroke();
// Label
ctx.globalAlpha = 0.8;
ctx.fillStyle = '#4f46e5';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(h.label, cx, cy - 5);
ctx.restore();
});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+26
View File
@@ -0,0 +1,26 @@
"""Outbound prerecorded announcement calls."""
import requests
from django.conf import settings
class AnnouncementRejected(ValueError):
"""The provider explicitly refused the requested announcement."""
def place_announcement_call(configuration, *, mobile, idempotency_key):
headers = {"Idempotency-Key": idempotency_key}
if configuration.announcement_api_key:
headers["X-API-Key"] = configuration.announcement_api_key
response = requests.post(
configuration.announcement_url,
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
headers=headers,
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid announcement provider response")
if payload.get("success") is False:
raise AnnouncementRejected("Announcement provider rejected the request")
View File
Whitespace-only changes.
+50
View File
@@ -0,0 +1,50 @@
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@dataclass
class NotificationResult:
success: bool
provider_id: str = ""
error: str = ""
class ConsoleBaleProvider:
def send_message(self, chat_id, text):
if settings.DEBUG: print(f"[DEV BALE] {chat_id or 'dashboard'}: {text}")
return NotificationResult(True, "console")
class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status(); data = response.json()
if not data.get("ok", True): return NotificationResult(False, error=str(data.get("description", "Bale rejected message")))
return NotificationResult(True, str((data.get("result") or {}).get("message_id", "bale")))
def get_bale_provider():
if settings.BALE_BOT_TOKEN:
return BaleBotProvider()
if settings.DEBUG:
return ConsoleBaleProvider()
raise ImproperlyConfigured("BALE_BOT_TOKEN is required to deliver Bale notifications in production.")
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from urllib.parse import urlparse
from django.conf import settings
@dataclass(frozen=True, slots=True)
class SMSResult:
success: bool
provider_id: str = ""
error: str = ""
provider_code: str = ""
@dataclass(frozen=True, slots=True)
class SMSDeliveryResult:
status: str
provider_code: str
description: str
is_final: bool = True
def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
return f"کد شما: {code}\n@{host} #{code}"
class SMSProvider(ABC):
"""Stable application-facing contract for every SMS vendor."""
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod
def send_message(self,mobile,message): raise NotImplementedError
def get_delivery_status(self, provider_id):
"""Return None when a provider does not support delivery tracking."""
return None
+69
View File
@@ -0,0 +1,69 @@
import logging
import zeep
from django.conf import settings
from zeep.helpers import serialize_object
from .base import SMSProvider, SMSResult
logger = logging.getLogger(__name__)
class BehinSMSProvider(SMSProvider):
"""SOAP adapter for panel.behinsms.com, isolated from application code."""
def __init__(self):
self.username = settings.BEHIN_SMS_USERNAME
self.password = settings.BEHIN_SMS_PASSWORD
self.special_number = settings.BEHIN_SMS_SPECIAL_NUMBER
self.wsdl_url = settings.BEHIN_SMS_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("BEHIN_SMS_USERNAME", self.username),
("BEHIN_SMS_PASSWORD", self.password),
("BEHIN_SMS_SPECIAL_NUMBER", self.special_number)
) if not value]
return f"تنظیمات پیامک بهین ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
# استفاده از تنظیمات پیش‌فرض Zeep (مشابه نمونهٔ کاری)
self._client = zeep.Client(wsdl=self.wsdl_url)
return self._client
def get_credit(self):
error = self._configuration_error()
if error:
raise ValueError(error)
return self.client.service.GetCredit(UserName=self.username, Password=self.password)
def send_message(self, mobile, message):
return self.send_many([mobile], message)
def send_many(self, mobile_numbers, message, is_flash=False):
error = self._configuration_error()
if error:
return SMSResult(False, error=error)
recipients = list(dict.fromkeys(str(number).strip() for number in mobile_numbers if str(number).strip()))
if not recipients:
return SMSResult(False, error="شماره گیرنده پیامک خالی است.")
try:
response = self.client.service.SendArray(
UserName=self.username,
Password=self.password,
RecipientNumber={"string": recipients},
MessageBody=message,
SpecialNumber=self.special_number,
IsFlashMessage=is_flash,
CheckingMessageID={"long": [0] * len(recipients)}
)
serialized = serialize_object(response)
if serialized is False or serialized is None:
return SMSResult(False, error="وب‌سرویس بهین ارسال پیامک را تأیید نکرد.")
return SMSResult(True, provider_id=str(serialized)[:500])
except Exception as exc:
logger.exception("Behin SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
+43
View File
@@ -0,0 +1,43 @@
"""Provider کنسول برای تست/دیباگ؛ پیامک واقعی ارسال نمی‌کند."""
import logging
import sys
from django.conf import settings
from .base import SMSProvider, SMSResult, webotp_message
logger = logging.getLogger(__name__)
def console_write(text):
"""چاپ روی کنسول به‌گونه‌ای که **هرگز** استثنا پرت نکند.
وظیفهٔ provider ارسال است، نه نمایش. کنسول ویندوز (cp1252) فارسی را کدگذاری
نمی‌کند و ``print`` مستقیم باعث ``UnicodeEncodeError`` می‌شد؛ چون فراخوانی‌کننده
(worker) هر استثنا را «ارسال ناموفق» می‌فهمد، یک خطای نمایشی پیامک را در صف
``failed`` می‌گذاشت. پس اول تلاش می‌کنیم متن کامل را بنویسیم، و اگر کنسول
توانش را نداشت نسخهٔ ASCII-شده را می‌نویسیم.
"""
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
sys.stdout.write(payload + "\n")
sys.stdout.flush()
return
except (UnicodeEncodeError, ValueError, OSError):
continue
class ConsoleSMSProvider(SMSProvider):
def __init__(self, config=None):
self.config = config
def send_otp(self, mobile, code):
if settings.DEBUG:
console_write(f"[DEV OTP] {mobile}: {webotp_message(code)}")
return SMSResult(True, provider_code="console")
def send_message(self, mobile, message):
logger.info("[DEV SMS] destination=***%s message=%s", (mobile or "")[-4:], message)
if settings.DEBUG:
console_write(f"[DEV SMS] {mobile}: {message}")
return SMSResult(True, provider_code="console")
+38
View File
@@ -0,0 +1,38 @@
"""انتخاب provider پیامک بر اساس تنظیمات ذخیره‌شده در دیتابیس (Django Admin)."""
from django.utils.module_loading import import_string
from .base import SMSProvider
# نگاشت کلید انتخاب‌شده در Admin به مسیر کلاس provider.
PROVIDER_CLASSES = {
"sms0098": "integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider",
"sms0098ws": "integrations.sms.sms0098ws.ZeroZeroNinetyEightWebServiceProvider",
"http": "integrations.sms.http.HTTPSMSProvider",
"console": "integrations.sms.console.ConsoleSMSProvider",
}
def get_provider_class(config):
path = PROVIDER_CLASSES.get(config.provider)
if not path:
raise ValueError(f"provider پیامک ناشناخته است: {config.provider}")
provider_class = import_string(path)
if not issubclass(provider_class, SMSProvider):
raise TypeError("کلاس provider باید از SMSProvider ارث‌بری کند.")
return provider_class
def get_sms_provider(config=None):
"""یک نمونه provider برای config داده‌شده (یا تنظیمات جاری) می‌سازد.
import مدل به‌صورت lazy انجام می‌شود تا از حلقهٔ import در زمان بارگذاری اپ جلوگیری شود.
"""
if config is None:
from admin_panel.models import SMSProviderConfig
config = SMSProviderConfig.load()
return get_provider_class(config)(config)
def reset_sms_provider():
"""سازگاری با فراخوانی‌های قبلی؛ provider دیگر کش نمی‌شود."""
return None
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more