Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6069c3ef | ||
|
|
250cdbab7f | ||
|
|
6f36db63a1 | ||
|
|
91768be145 |
No files matched your search
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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-*/
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin": [
|
||||
".opencode/plugins/graphify.js"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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='ارسال خودکار (بدون تأیید مدیر)'),
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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="تلفن مالک چک"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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})"
|
||||
@@ -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("تعداد انتقال باید بیشتر از صفر باشد.")
|
||||
@@ -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}'
|
||||
@@ -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})"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,17 +45,17 @@
|
||||
<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 %}
|
||||
{# همهٔ شمارهها از هر منبعی که ذخیره شدهاند (جدول شمارهها + فیلدهای قدیمی)، بدون تکرار #}
|
||||
{% for entry in sms_phone_entries %}
|
||||
<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="عنوان">
|
||||
<input disabled value="{{ entry.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="تلفن">
|
||||
<input disabled value="{{ entry.display }}" 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 href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
|
||||
📞
|
||||
</a>
|
||||
</div>
|
||||
@@ -65,66 +65,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="help mt-2">شمارهای برای این مشتری ثبت نشده است.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
{% if customer.phone1 %}
|
||||
<div class="field has-addons ss02 mt-2">
|
||||
<div class="control" style="width: 30%;">
|
||||
<input disabled value="تلفن 1" name="phone_titles" class="input" type="text" placeholder="عنوان">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input disabled value="0{{ customer.phone1 }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="tel:0{{ customer.phone1 }}" class="button is-success" title="تماس">
|
||||
📞
|
||||
</a>
|
||||
</div>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer.phone2 %}
|
||||
<div class="field has-addons ss02 mt-2">
|
||||
<div class="control" style="width: 30%;">
|
||||
<input disabled value="تلفن 2" name="phone_titles" class="input" type="text">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input disabled value="0{{ customer.phone2 }}" name="phones" class="input an_phone" type="text">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="tel:0{{ customer.phone2 }}" class="button is-success" title="تماس">
|
||||
📞
|
||||
</a>
|
||||
</div>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer.phone3 %}
|
||||
<div class="field has-addons ss02 mt-2">
|
||||
<div class="control" style="width: 30%;">
|
||||
<input disabled value="تلفن 3" name="phone_titles" class="input" type="text">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input disabled value="0{{ customer.phone3 }}" name="phones" class="input an_phone" type="text">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="tel:0{{ customer.phone3 }}" class="button is-success" title="تماس">
|
||||
📞
|
||||
</a>
|
||||
</div>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -136,63 +136,87 @@
|
||||
<button class="modal-close is-large closeSearch" aria-label="close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal addNewUser">
|
||||
<div class="modal addNewUser" role="dialog" aria-modal="true" aria-labelledby="addUserTitle">
|
||||
<div class="modal-background closeAddUser"></div>
|
||||
<div class="modal-content">
|
||||
<form action="{% url 'admin_panel:customersAdd' %}" class="columns is-mobile is-multiline" method="post">
|
||||
<div class="modal-content add-modal">
|
||||
<div class="add-modal-card">
|
||||
<header class="add-modal-head">
|
||||
<h2 class="title is-5 mb-0" id="addUserTitle">ایجاد مشتری جدید</h2>
|
||||
<button type="button" class="delete closeAddUser" aria-label="بستن"></button>
|
||||
</header>
|
||||
|
||||
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
|
||||
{% csrf_token %}
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">نام ونام خانوادگی</label>
|
||||
<div class="add-modal-body">
|
||||
<div class="columns is-mobile is-multiline m-0">
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
|
||||
<label class="label" for="addName">نام و نام خانوادگی <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
|
||||
<input required name="an_name" id="addName" class="input an_name" type="text"
|
||||
autocomplete="name" placeholder="نام و نام خانوادگی">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile m-0">
|
||||
<label class="label">آدرس</label>
|
||||
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
|
||||
<label class="label" for="addIdnCode">کدشناسایی</label>
|
||||
<div class="control">
|
||||
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
|
||||
<input name="idnCode" id="addIdnCode" class="input idnCode" type="text"
|
||||
placeholder="کد شناسایی مشتری">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
|
||||
<label class="label" for="persianDateP">تاریخ نصب</label>
|
||||
<div class="control">
|
||||
<input name="installDate" id="persianDateP" class="input installDate" type="text"
|
||||
autocomplete="off" placeholder="مثل: 1400/06/01">
|
||||
</div>
|
||||
<p class="help">مبنای محاسبهٔ یادآوری بازدید دورهای.</p>
|
||||
</div>
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
|
||||
<label class="label" for="addAddress">آدرس</label>
|
||||
<div class="control">
|
||||
<input name="an_address" id="addAddress" class="input an_address" type="text"
|
||||
autocomplete="street-address" placeholder="آدرس کامل">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12 py-2 px-2">
|
||||
<label class="label">شمارههای تماس</label>
|
||||
<div id="dynamic-phones-add">
|
||||
<div class="field has-addons">
|
||||
<div class="control" style="width: 35%;">
|
||||
<input name="phone_titles" class="input" type="text" placeholder="عنوان (مثلاً موبایل)">
|
||||
<div class="phone-row">
|
||||
<div class="phone-row-fields">
|
||||
<input name="phone_titles" class="input phone-title" type="text"
|
||||
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
|
||||
<input name="phones" class="input phone-number an_phone" type="tel"
|
||||
inputmode="tel" autocomplete="tel" aria-label="شماره تماس"
|
||||
placeholder="شماره تماس">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-info add-phone-btn">+</button>
|
||||
<div class="phone-row-actions">
|
||||
<label class="checkbox phone-primary" title="گیرندهٔ پیامکهای این مشتری">
|
||||
<input type="checkbox" name="phone_primary" value="0" checked> اصلی
|
||||
</label>
|
||||
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
|
||||
aria-label="حذف این شماره">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button is-info is-light is-small add-phone-btn"
|
||||
data-target="#dynamic-phones-add">+ افزودن شماره</button>
|
||||
<p class="help">تیک «اصلی» یعنی گیرندهٔ پیامکهای این مشتری (میتواند چند شماره باشد).</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">تاریخ نصب</label>
|
||||
<div class="control">
|
||||
<input name="installDate" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">کدشناسایی</label>
|
||||
<div class="control">
|
||||
<input name="idnCode" class="input idnCode" type="text" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<input class="button is-success" id="DcreateCS" value="ایجاد مشتری" type="submit">
|
||||
</div>
|
||||
|
||||
<footer class="add-modal-foot">
|
||||
<button class="button is-success" id="DcreateCS" type="submit">ایجاد مشتری</button>
|
||||
<button class="button is-light closeAddUser" type="button">انصراف</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large closeAddUser" aria-label="close"></button>
|
||||
</div>
|
||||
@@ -252,51 +276,131 @@
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block cssEnd %}
|
||||
<style>
|
||||
/* --- مودال ایجاد مشتری: کارت با سربرگ/پابرگ چسبان و بدنهٔ اسکرولشو --- */
|
||||
/* .add-modal همان .modal-content بولماست؛ سقف ارتفاع و max-width پیشفرضش را خنثی
|
||||
میکنیم تا کارت خودش اندازه را تعیین کند (وگرنه پابرگ بریده میشود). */
|
||||
.add-modal {
|
||||
overflow: visible; max-height: none; max-width: 760px;
|
||||
width: 100%; margin: 0 auto; padding: 0 .5rem;
|
||||
}
|
||||
.add-modal-card {
|
||||
display: flex; flex-direction: column;
|
||||
background: #fff; border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
|
||||
width: 100%; max-width: 720px; margin: 0 auto;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
/* فرم باید خودش ستون فلکس باشد تا بدنه بتواند اسکرول شود و پابرگ پایین بماند. */
|
||||
.add-modal-card > form {
|
||||
display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
|
||||
}
|
||||
.add-modal-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: .5rem; padding: .75rem 1rem;
|
||||
border-bottom: 1px solid #eee; background: #fff; flex: 0 0 auto;
|
||||
}
|
||||
.add-modal-body {
|
||||
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
padding: .25rem .5rem; flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
.add-modal-foot {
|
||||
display: flex; gap: .5rem; padding: .75rem 1rem;
|
||||
border-top: 1px solid #eee; background: #fafafa; flex: 0 0 auto;
|
||||
}
|
||||
.add-modal-foot .button { min-height: 2.6em; }
|
||||
|
||||
/* --- ردیف شمارهٔ تماس: دسکتاپ یکخطی، موبایل عمودی --- */
|
||||
.phone-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
|
||||
padding: .5rem; margin-bottom: .5rem;
|
||||
border: 1px solid #e6e6e6; border-radius: 8px; background: #fafafa;
|
||||
}
|
||||
.phone-row-fields { display: flex; flex-wrap: wrap; gap: .5rem; flex: 1 1 320px; min-width: 0; }
|
||||
.phone-row-fields .phone-title { flex: 0 0 140px; min-width: 0; }
|
||||
.phone-row-fields .phone-number { flex: 1 1 180px; min-width: 0; }
|
||||
.phone-row-actions { display: flex; align-items: center; gap: .75rem; white-space: nowrap; }
|
||||
.phone-primary { display: flex; align-items: center; gap: .35rem; font-size: .9rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.add-modal-card { max-height: calc(100vh - 32px); border-radius: 8px; }
|
||||
/* dvh روی مرورگرهای موبایل نوار آدرس را هم حساب میکند. */
|
||||
.add-modal-card { max-height: calc(100dvh - 32px); }
|
||||
.phone-row-fields { flex: 1 1 100%; }
|
||||
.phone-row-fields .phone-title,
|
||||
.phone-row-fields .phone-number { flex: 1 1 100%; }
|
||||
.phone-row-actions { flex: 1 1 100%; justify-content: space-between; }
|
||||
.add-modal-foot { flex-direction: column-reverse; }
|
||||
.add-modal-foot .button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block jsEnd %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
|
||||
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
|
||||
|
||||
$(document).on('click', '.add-phone-btn', function() {
|
||||
var phoneRow = `
|
||||
<div class="field has-addons mt-2">
|
||||
<div class="control" style="width: 35%;">
|
||||
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
|
||||
var PHONE_ROW = `
|
||||
<div class="phone-row">
|
||||
<div class="phone-row-fields">
|
||||
<input name="phone_titles" class="input phone-title" type="text"
|
||||
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
|
||||
<input name="phones" class="input phone-number an_phone" type="tel"
|
||||
inputmode="tel" autocomplete="tel" aria-label="شماره تماس" placeholder="شماره تماس">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger remove-phone-btn">-</button>
|
||||
<div class="phone-row-actions">
|
||||
<label class="checkbox phone-primary" title="گیرندهٔ پیامکهای این مشتری">
|
||||
<input type="checkbox" name="phone_primary" value="0"> اصلی
|
||||
</label>
|
||||
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
|
||||
aria-label="حذف این شماره">حذف</button>
|
||||
</div>
|
||||
</div>`;
|
||||
$(this).closest('div[id^="dynamic-phones"]').append(phoneRow);
|
||||
|
||||
function reindexPhones() {
|
||||
$('#dynamic-phones-add .phone-row').each(function(i) {
|
||||
$(this).find('input[name="phone_primary"]').val(i);
|
||||
});
|
||||
}
|
||||
|
||||
// همیشه دستکم یک شماره «اصلی» بماند (شمارهٔ اول پیشفرضِ گیرندهٔ پیامک است).
|
||||
function ensurePrimary() {
|
||||
var $rows = $('#dynamic-phones-add .phone-row');
|
||||
if ($rows.length && $rows.find('input[name="phone_primary"]:checked').length === 0) {
|
||||
$rows.first().find('input[name="phone_primary"]').prop('checked', true);
|
||||
}
|
||||
}
|
||||
|
||||
reindexPhones();
|
||||
|
||||
$(document).on('click', '.add-phone-btn', function() {
|
||||
// data-target صریح (وقتی دکمه بیرونِ ظرف است)؛ وگرنه نزدیکترین ظرف شمارهها.
|
||||
var $target = $(this).data('target')
|
||||
? $($(this).data('target'))
|
||||
: $(this).closest('div[id^="dynamic-phones"]');
|
||||
$target.append(PHONE_ROW);
|
||||
reindexPhones();
|
||||
$target.find('.phone-row').last().find('.phone-number').focus();
|
||||
});
|
||||
|
||||
$(document).on('click', '.remove-phone-btn', function() {
|
||||
$(this).closest('.field.has-addons').remove();
|
||||
$(this).closest('.phone-row').remove();
|
||||
reindexPhones();
|
||||
ensurePrimary();
|
||||
});
|
||||
|
||||
$("#createCS").click(function(){
|
||||
var u_name = $(".addNewUser").find('.an_name').val();
|
||||
var u_address = $(".addNewUser").find('.an_address').val();
|
||||
var u_phone1 = $(".addNewUser").find('#phone-1').val();
|
||||
var u_phone2 = $(".addNewUser").find('#phone-2').val();
|
||||
var u_phone3 = $(".addNewUser").find('#phone-3').val();
|
||||
var u_installDate = $(".addNewUser").find('.installDate').val();
|
||||
alert("Ok send To server");
|
||||
function resetAddForm() {
|
||||
$('#addCustomerForm').find('input.an_name, input.an_address, input.installDate, input.idnCode').val('');
|
||||
$('#dynamic-phones-add').empty().append(PHONE_ROW);
|
||||
reindexPhones();
|
||||
ensurePrimary();
|
||||
}
|
||||
|
||||
$(".closeAddUser").click(function(){
|
||||
$(".addNewUser").removeClass("is-active");
|
||||
|
||||
$(".addNewUser").find('.an_name').val('');
|
||||
$(".addNewUser").find('.an_address').val('');
|
||||
$(".addNewUser").find('#phone-1').val('');
|
||||
$(".addNewUser").find('#phone-2').val('');
|
||||
$(".addNewUser").find('#phone-3').val('');
|
||||
$(".addNewUser").find('.installDate').val('');
|
||||
//Refresh page
|
||||
|
||||
resetAddForm();
|
||||
});
|
||||
$(".modal-close, .modal-background").click(function(){
|
||||
$(".mEdit1").removeClass("is-active")
|
||||
@@ -304,20 +408,25 @@ $(document).ready(function() {
|
||||
$(".closeSearch").click(function(){
|
||||
$(".SearchBox").removeClass("is-active")
|
||||
});
|
||||
$(".closeAddUser").click(function(){
|
||||
$(".addNewUser").removeClass("is-active");
|
||||
$(".addNewUser").find('.an_name').val('');
|
||||
$(".addNewUser").find('.an_address').val('');
|
||||
$(".addNewUser").find('#phone-1').val('');
|
||||
$(".addNewUser").find('#phone-2').val('');
|
||||
$(".addNewUser").find('#phone-3').val('');
|
||||
$(".addNewUser").find('.installDate').val('');
|
||||
});
|
||||
|
||||
$("#addCustomerForm").on('submit', function(){
|
||||
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا دادهها را بسته است.
|
||||
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
|
||||
});
|
||||
|
||||
$("#addNewUser").click(function(){
|
||||
$(".addNewUser").addClass("is-active")
|
||||
resetAddForm();
|
||||
$(".addNewUser").addClass("is-active");
|
||||
$('#addName').trigger('focus');
|
||||
});
|
||||
|
||||
$(document).on('keydown', function(e){
|
||||
if (e.key === 'Escape' && $(".addNewUser").hasClass("is-active")) {
|
||||
$(".addNewUser").removeClass("is-active");
|
||||
resetAddForm();
|
||||
}
|
||||
});
|
||||
|
||||
$("#openSearchBox").click(function(){
|
||||
$(".SearchBox").addClass("is-active")
|
||||
});
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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('لطفا همه فیلد ها را پر کنید')
|
||||
@@ -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/")
|
||||
@@ -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": "خطای سرور در برقراری ارتباط با سامانه بانک."})
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
+15
-2
@@ -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)
|
||||
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
|
||||
@@ -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 که هنوز اجرا نشده، همهچیز کامل حفظ میشود.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
.
|
||||
@@ -0,0 +1 @@
|
||||
{"output_tokens": 126792}
|
||||
@@ -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
@@ -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 · 2237 edges · 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'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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// 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>
|
||||
+41667
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -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")
|
||||
Whitespace-only changes.
@@ -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.")
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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
Reference in new issue
Block a user