Compare commits

..
19 Commits
Author SHA1 Message Date
shahzeydyfah ee3a5025c1 fix(report):add change 2026-10-03 22:29:07 +03:30
shahzeydyfah 0cbf543940 fix(project):sort 2026-09-13 21:53:35 +03:30
shahzeydyfah b74b0067a6 fix(project):filter sts in monitor or design 2026-09-13 21:25:06 +03:30
shahzeydyfah 6cd225f395 fix(report):order progress_month_report 2026-08-23 00:22:22 +03:30
shahzeydyfah 4ddac54ab7 fix(report):order progress_month_report 2026-08-17 21:38:03 +03:30
shahzeydyfah 439f7b3c15 fix(report):check design projects 2026-08-10 23:32:52 +03:30
shahzeydyfah 9fd5481461 fix(project):get list with ids 2026-06-19 23:51:11 +03:30
shahzeydyfah 198784f144 fix(report):add design progress gant chart 2026-06-14 23:40:15 +03:30
shahzeydyfah 759c2216c6 fix(excel):change 2026-06-10 00:31:45 +03:30
shahzeydyfah d1f6c1830f fix(project):add created_at and updated_at in move projects 2026-05-01 23:11:12 +03:30
shahzeydyfah c7b1929fdc feat(root):add mysql 2026-02-18 00:03:54 +03:30
shahzeydyfah 189df529d9 feat(root):add mysql 2026-02-17 23:52:14 +03:30
shahzeydyfah abb1c01753 feat(root):add mysql 2026-02-17 22:37:43 +03:30
shahzeydyfah 7e25cd6674 feat(root):add mysql 2026-02-17 22:33:24 +03:30
shahzeydyfah db0cc4007c feat(core):add map file for excel 2026-02-15 22:52:11 +03:30
shahzeydyfah 3061b86524 feat(register status):compact with financial adjust 2026-02-12 13:55:15 +03:30
shahzeydyfah cc7cd9876c feat(register status):compact with financial status 2026-02-12 13:33:14 +03:30
shahzeydyfah f7426fb6ea feat(mongo):ignore limit 2026-02-05 23:33:57 +03:30
shahzeydyfah eae9248dc5 feat(root):add create export 2026-02-05 23:02:08 +03:30
33 changed files with 1722 additions and 724 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ class SubRecord(pydantic.BaseModel):
class Record(BaseModel):
id: Optional[uuid.UUID] = Field(alias='_id')
title: str = Field(description='عنوان در این فیلد قرار میگیرد')
send: SubRecord = Field(description='اطلاعات ارسال در این فیلد ذخیره می شود')
send: Optional[SubRecord] = Field(description='اطلاعات ارسال در این فیلد ذخیره می شود')
receive: Optional[SubRecord] = Field(description='اطلاعات دریافت در این فیلد ذخیره می شود')
class RecordModel(Record):
+27
View File
@@ -0,0 +1,27 @@
"""
مدل ORM برای جدول const در MySQL.
"""
from sqlalchemy import Column, Integer, String, Text
from core.service.mysql_orm import Base
class Const(Base):
"""مدل جدول const"""
__tablename__ = "const"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), nullable=False, index=True)
title_fa = Column(String(255), nullable=True)
value = Column(Text, nullable=True)
def __repr__(self):
return f"<Const(id={self.id}, key='{self.key}', title_fa='{self.title_fa}')>"
def to_dict(self):
"""تبدیل به dict برای JSON response"""
return {
"id": self.id,
"key": self.key,
"title_fa": self.title_fa,
"value": self.value,
}
+3
View File
@@ -151,5 +151,8 @@ class ProjectStatusAgreement(BaseModel):
project_id: Optional[uuid.UUID] = Field(description="کد شناسایی پروژه در این فیلد قرار می گیرد")
status:str
class ListWithIds(pydantic.BaseModel):
ids: list[uuid.UUID]
+2
View File
@@ -48,3 +48,5 @@ class RegisterStatusModel(BaseModel):
amount: Optional[AmountStatus] = Field(description="مبالغ")
related_to_number_start:Optional[int] = Field(description="شماره صورت وضعیت مرتبط")
related_to_number_end:Optional[int] = Field(description="شماره صورت وضعیت مرتبط")
date_at: Optional[datetime.datetime] = Field(description='تاریخ پرداخت')
amount_ّfinancial: Optional[int] = Field(description='مبلغ صورت وضعیت در قسمت مالی')
+5 -1
View File
@@ -22,10 +22,14 @@ class DelayDonutDetail(pydantic.BaseModel):
project_id:uuid.UUID
select:Literal['financial','force_major','technical']
class DonutReport(pydantic.BaseModel):
only_current: bool = Field(default=False, description='فقط پروژه‌های نظارت جاری؛ مقدار false برای همه پروژه‌های نظارت')
class DonutList(pydantic.BaseModel):
type: int = Field(description='مقدار وارد شده در گزارش قبل')
donut_select: str = Field(description='آیتم دونات انتخاب شده')
class PhysicalMonth(pydantic.BaseModel):
from_date:datetime.datetime
to_date:datetime.datetime
to_date:datetime.datetime
+4 -3
View File
@@ -87,6 +87,7 @@ class ContractRepository(BaseRepository):
{'$match':query['query']},
{'$group': {"_id": None, 'count': {'$sum': 1},'sum':{"$sum":"$amount"}}}
]).to_list(None)
return count[0]
else:
return []
print(count)
if len(count)>0:
return count[0]
return []
+740 -652
View File
File diff suppressed because it is too large Load Diff
@@ -139,6 +139,34 @@ class PhysicalProgressRepository(BaseRepository):
else:
await use_collection(COLLECTION).update_one({"_id": _i_model.id},{"$set": _update})
return True
# for recalc medium progress in database
# async def recalc_medium_progress(self):
# print('111111111111111111111')
# coll = use_collection(TaskEnv.CLN_MONITOR_PYSICAL)
# project_ids = await coll.distinct(
# "project_id",
# {"deleted_at": {"$exists": False}},
# )
# print('222222222222222222222')
#
# updated = 0
# for pid in project_ids:
# print('piiiiiidf',pid)
# records = await coll.find({
# "project_id": pid,
# "deleted_at": {"$exists": False},
# }).sort([("date_at", 1), ("_id", 1)]).to_list(None)
#
# total = 0
# for i, rec in enumerate(records, start=1):
# total += rec.get("real") or 0
# await coll.update_one(
# {"_id": rec["_id"]},
# {"$set": {"medium_progress": round(total / i, 1)}},
# )
# updated += 1
#
# return {"projects": len(project_ids), "updated": updated}
+34
View File
@@ -0,0 +1,34 @@
"""
Repository برای جدول const در MySQL - برای استفاده در خروجی اکسل.
"""
from typing import List, Dict, Optional
from sqlalchemy import select
from core.service.mysql_orm import get_mysql_session
from app.models.mysql_const import Const
class MySQLConstRepository:
"""Repository برای خواندن دیتا از جدول const"""
async def get_by_keys(self, keys: List[str]) -> Dict[int, str]:
"""
گرفتن const ها با لیست key ها و برگرداندن dict به صورت {id: title_fa}
مثال: get_by_keys(['nature', 'type']) -> {2: 'طبیعت 2', 9: 'نوع 9'}
"""
async with get_mysql_session() as session:
result = await session.execute(
select(Const).where(Const.key.in_(keys))
)
consts = result.scalars().all()
return {c.id: c.title_fa or c.value or str(c.id) for c in consts}
async def get_by_key(self, key: str) -> Dict[int, str]:
"""گرفتن const ها با یک key و برگرداندن dict به صورت {id: title_fa}"""
return await self.get_by_keys([key])
async def get_all_consts_dict(self) -> Dict[int, str]:
"""گرفتن همه const ها به صورت {id: title_fa} - برای کش کردن"""
async with get_mysql_session() as session:
result = await session.execute(select(Const))
consts = result.scalars().all()
return {c.id: c.title_fa or c.value or str(c.id) for c in consts}
+76 -14
View File
@@ -7,7 +7,7 @@ import requests
from fastapi import HTTPException
from app.config import TaskEnv
from app.models.project import ProjectModel, UpdateSts, ProjectSts, Design, ProjectStatusAgreement
from app.models.project import ProjectModel, UpdateSts, ProjectSts, Design, ProjectStatusAgreement, ListWithIds
from core.repositories.base import BaseRepository
from core.service.mongo_db import use_collection
from core.utils.table_engine import TableEngineRequestType, TableEngineExport
@@ -66,14 +66,14 @@ class ProjectRepository(BaseRepository[ProjectModel]):
async def create(self, coding: ProjectModel):
coding.counter = 1
last_project = await use_collection(COLLECTION).find({}).sort("$natural", -1).to_list(None)
last_project = await use_collection(COLLECTION).find({}).sort("counter", -1).to_list(None)
if last_project:
coding.counter = last_project[0]["counter"] + 1
code = self.create_code(coding)
coding.design.code = 'D' + code
coding.design.code = code
coding.init_id().cu_at()
if coding.monitor:
coding.monitor.code = 'S' + code
coding.monitor.code = code
coding.has_monitor = True
await self.create_report_price(coding)
if coding.monitor.detail.date_create_project and coding.monitor.detail.first_time:
@@ -95,12 +95,12 @@ class ProjectRepository(BaseRepository[ProjectModel]):
old_project = ProjectModel(**old_project_dict)
if update.monitor and not old_project.monitor:
code = self.create_code(update)
update.monitor.code = 'S' + code
update.monitor.code = code
if update.design.nature != old_project.design.nature or update.design.type != old_project.design.type:
code = self.create_code(update)
update.design.code = 'D' + code
update.design.code = code
if update.monitor:
update.monitor.code = 'S' + code
update.monitor.code = code
if update.monitor:
update.has_monitor = True
await self.update_report_price(update)
@@ -175,7 +175,16 @@ class ProjectRepository(BaseRepository[ProjectModel]):
return True
async def list_project_design(self):
project = await use_collection(COLLECTION).find({'monitor': {"$exists": 0}}).to_list(None)
project = await use_collection(COLLECTION).find({
'$or': [
{'has_monitor': False},
{'monitor': {'$exists': 0}},
{
'has_monitor': True,
'monitor.has_design': True
}
]
}).to_list(None)
return project
async def list_project_monitor(self):
@@ -200,6 +209,13 @@ class ProjectRepository(BaseRepository[ProjectModel]):
project = await use_collection(TaskEnv.CLN_PROJECT).find({}).to_list(None)
return project
async def list_project_with_select(self):
project = await use_collection(TaskEnv.CLN_PROJECT).find({},{"_id":1,"design.title":1,"design.code":1}).to_list(None)
return project
async def list_project_with_ids(self,inp:ListWithIds):
projects = await use_collection(TaskEnv.CLN_PROJECT).find({"_id":{"$in":inp.ids}}).to_list(None)
return projects
async def set_sts_two_for_monitor(self, project_id: uuid.UUID):
project = await use_collection(TaskEnv.CLN_PROJECT).find_one({"_id": project_id})
if not ('sts' in project['monitor'] and project['monitor']['sts'] > 1):
@@ -224,25 +240,69 @@ class ProjectRepository(BaseRepository[ProjectModel]):
return projects
@staticmethod
def _has_design_sts_filter(te_input) -> bool:
if not te_input:
return False
f_list = getattr(te_input, 'f', None) if not isinstance(te_input, dict) else te_input.get('f')
if not f_list:
return False
for scope in f_list:
items = scope.get('items', []) if isinstance(scope, dict) else getattr(scope, 'items', [])
if items:
for item in items:
col = item.get('col') if isinstance(item, dict) else getattr(item, 'col', '')
if col == 'design.sts':
return True
return False
async def fetch_delete(self, _input: TableEngineRequestType) -> TableEngineExport[T]:
return await NewTableEngine.mongo(
extra_filter = [{'sts': 2}]
if self._has_design_sts_filter(_input.te if _input else None):
extra_filter.append({
'$or': [
{'has_monitor': False},
{'monitor': {'$exists': False}},
{'has_monitor': True, 'monitor.has_design': True}
]
})
result = await NewTableEngine.mongo(
query_class=use_collection(self.COLLECTION),
soft_deleted=True,
model=self.MODEL,
search_cols=['design.title'],
extra_filter=[{'sts': 2}],
extra_filter=extra_filter,
inputs=_input.te) \
.to_dict()
if result and result.rows:
for row in result.rows:
if row.monitor and row.monitor.has_design is False:
row.design.sts = None
return result
async def fetch(self, _input: TableEngineRequestType) -> TableEngineExport[T]:
return await NewTableEngine.mongo(
extra_filter = [{'sts': 1}]
if self._has_design_sts_filter(_input.te if _input else None):
extra_filter.append({
'$or': [
{'has_monitor': False},
{'monitor': {'$exists': False}},
{'has_monitor': True, 'monitor.has_design': True}
]
})
result = await NewTableEngine.mongo(
query_class=use_collection(self.COLLECTION),
soft_deleted=True,
model=self.MODEL,
search_cols=['design.title'],
extra_filter=[{'sts': 1}],
extra_filter=extra_filter,
inputs=_input.te) \
.to_dict()
if result and result.rows:
for row in result.rows:
if row.monitor and row.monitor.has_design is False:
row.design.sts = None
return result
async def get_all(self):
url = 'http://api.khanesaziceco.ir/api/api/project/table'
@@ -261,8 +321,10 @@ class ProjectRepository(BaseRepository[ProjectModel]):
_set = ProjectModel(
sts=1,
counter=i['id'],
design=Design(**i)
).init_id().cu_at()
design=Design(**i),
created_at=i['created_at'],
updated_at=i['updated_at'],
).init_id()
obj.append(_set.mongo_dict())
await use_collection(TaskEnv.CLN_PROJECT).insert_many(obj)
+54
View File
@@ -19,3 +19,57 @@ class RegisterStatusRepository(BaseRepository):
async def get_list(self,project_id:uuid.UUID):
list_status = await use_collection(TaskEnv.CLN_AUDIT_STATUS).find({"project_id":project_id}).to_list(None)
return list_status
async def create(self, coding: RegisterStatusModel):
result = await super().create(coding)
price = await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).find_one({'project_id': coding.project_id})
if price and coding.amount_ّfinancial:
if coding.sts ==1:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': coding.project_id},
{"$inc": {'amount_status': coding.amount_ّfinancial}}
)
elif coding.sts == 2:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': coding.project_id},
{"$inc": {'amount_adjust': coding.amount_ّfinancial}}
)
return result
async def update(self, _id: uuid.UUID, _update: RegisterStatusModel):
_old_dic = await use_collection(TaskEnv.CLN_AUDIT_STATUS).find_one({'_id':_id})
change = 0
result = await super().update(_id, _update)
price = await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).find_one({'project_id': _update.project_id})
if price:
_old = RegisterStatusModel(**_old_dic)
if 'amount_ّfinancial' not in _old_dic and _update.amount_ّfinancial:
change = _update.amount_ّfinancial
if _old.amount_ّfinancial != _update.amount_ّfinancial:
change = _update.amount_ّfinancial - _old.amount_ّfinancial
if change != 0 :
if _old.sts == 1:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': _update.project_id},
{"$inc": {'amount_status': change}}
)
elif _old.sts == 2:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': _update.project_id},
{"$inc": {'amount_adjust': change}}
)
return result
async def delete(self, _id: uuid.UUID):
_old_dic = await use_collection(TaskEnv.CLN_AUDIT_STATUS).find_one({'_id': _id})
await super().delete(_id)
price = await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).find_one({'project_id': _old_dic['project_id']})
if price and _old_dic['sts'] == 1 and 'amount_status' in price:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': _old_dic['project_id']},
{"$inc": {'amount_status': -(_old_dic['amount_ّfinancial'])}}
)
if price and _old_dic['sts'] == 2 and 'amount_adjust' in price:
await use_collection(TaskEnv.CLN_PROJECT_PRICE_REPORT).update_one(
{'project_id': _old_dic['project_id']},
{"$inc": {'amount_adjust': -(_old_dic['amount_ّfinancial'])}}
)
return True
+187 -9
View File
@@ -3,20 +3,30 @@ from datetime import time
from app.config import TaskEnv
from app.models.project import ProjectPriceReport
from app.models.report import StatusReport, ReportCountry, DonutList, DelayDonutDetail, PhysicalMonth
from app.models.report import StatusReport, ReportCountry, DonutList, DonutReport, DelayDonutDetail, PhysicalMonth
from core.service.mongo_db import use_collection
class ReportRepository():
async def project_status(self, _inp: StatusReport, type):
if _inp.state:
match = {f'{type}.sts': {'$exists': 1}, 'design.state': _inp.state}
match = {f'{type}.sts': {'$exists': 1}, 'design.state': _inp.state,'sts':1}
else:
match = {f'{type}.sts': {'$exists': 1}}
match = {f'{type}.sts': {'$exists': 1},'sts':1}
if _inp.type_id:
match.update({'design.type_id': _inp.type_id})
if _inp.nature_id:
match.update({'design.nature_id': _inp.nature_id})
if type == 'design':
match.update({
'$or': [
{'has_monitor': False},
{
'monitor.has_design': True,
'has_monitor': True
}
]
})
if type == 'monitor' and _inp.monitor_type:
match.update({'monitor.base_information.type': _inp.monitor_type})
report = await use_collection(TaskEnv.CLN_PROJECT).aggregate([
@@ -140,6 +150,64 @@ class ReportRepository():
]).to_list(None)
return report
async def donut_v2(self, _select: int, _inp: DonutReport):
project_filter = {"has_monitor": True}
if _inp.only_current:
project_filter["monitor.sts"] = 2
report = None
if _select == 1:
report = await use_collection(TaskEnv.CLN_PROJECT).aggregate([
{"$match": project_filter},
{"$group": {"_id": "$design.nature_id", 'count': {'$sum': 1}}}
]).to_list(None)
elif _select == 2:
report = await use_collection(TaskEnv.CLN_PROJECT).aggregate([
{"$match": project_filter},
{
"$group": {
"_id": {
"$cond": [
{"$lt": ["$design.building_infrastructure", 1000]}, # Check if a < 10
"less_than_1000",
{
"$cond": [
{"$and": [{"$gte": ["$design.building_infrastructure", 1000]},
{"$lt": ["$design.building_infrastructure", 5000]}]},
# Check if 10 <= a < 20
"between_1000_and_5000",
{
"$cond": [
{"$and": [{"$gte": ["$design.building_infrastructure", 5000]},
{"$lt": ["$design.building_infrastructure", 15000]}]},
"between_5000_and_15000",
{
"$cond": [
{"$and": [{"$gte": ["$design.building_infrastructure", 15000]},
{"$lt": ["$design.building_infrastructure", 30000]}]},
"between_15000_and_30000",
"greater_than_or_equal_30000" # Otherwise, a >= 30
]
}
]
}
]
}
]
},
"total": {"$sum": 1},
}
}
]).to_list(None)
elif _select == 3:
report = await use_collection(TaskEnv.CLN_PROJECT).aggregate([
{"$match": project_filter},
{"$group": {"_id": "$design.type_id", 'count': {'$sum': 1}}}
]).to_list(None)
return report
async def list_donut(self, _inp: DonutList):
_filter = None
if _inp.type == 1:
@@ -220,6 +288,13 @@ class ReportRepository():
async def categorize_deviation(self):
report = await use_collection(TaskEnv.CLN_MONITOR_PYSICAL).aggregate(
[{"$match": {"deviation": {"$exists": True, "$ne": None}}},
{"$lookup": {
"from": TaskEnv.CLN_PROJECT,
"localField": "project_id",
"foreignField": "_id",
"as": "project"
}},
{"$match": {"project.monitor.sts": 2}},
{"$group": {
"_id": {
"$cond": [
@@ -257,6 +332,28 @@ class ReportRepository():
]).to_list(None)
return report
async def delay_donut_all_v2(self, _inp: DonutReport):
project_filter = {"project.has_monitor": True}
if _inp.only_current:
project_filter["project.monitor.sts"] = 2
report = await use_collection(TaskEnv.CLN_BILL_DELAY).aggregate([
{"$lookup": {
"from": TaskEnv.CLN_PROJECT,
"localField": "project_id",
"foreignField": "_id",
"as": "project"
}},
{"$match": project_filter},
{'$group': {"_id": None, "total_financial": {"$sum": "$financial.total"},
"total_force_major": {"$sum": "$force_major.total"},
"total_technical": {"$sum": "$technical.total"},
"total_extra_new_work": {"$sum": "$extra_new_work"},
"total_other": {"$sum": "$other"},
}}
]).to_list(None)
return report
async def list_delay_donut(self, _select: str):
get_list = []
_map = {'1': 'financial.total', '2': 'force_major.total', '3': 'technical.total', '4': 'extra_new_work',
@@ -402,20 +499,55 @@ class ReportRepository():
to_date = datetime.combine(_inp.to_date.date(), time.max)
from_date = _inp.from_date.combine(_inp.from_date.date(), time.min)
result = await use_collection(TaskEnv.CLN_MONITOR_PYSICAL).aggregate([
{"$match":{"date_at":{"$lte":to_date,"$gte":from_date}}},
{
"$sort": {'date_at': -1}
"$match": {
"date_at": {
"$lte": to_date,
"$gte": from_date
}
}
},
{"$group":
{'_id': {"year": "$year", "month": "$month"},
'avg': {'$avg': "$real"}},
}
{
"$lookup": {
"from": TaskEnv.CLN_PROJECT,
"localField": "project_id",
"foreignField": "_id",
"as": "project"
}
},
{"$match": {"project.monitor.sts": 2}},
{
"$group": {
"_id": {
"year": "$year",
"month": "$month"
},
"avg": {
"$avg": "$real"
},
"sort_date": {
"$max": "$date_at"
}
}
},
{
"$sort": {
"sort_date": -1
}
}
]).to_list(None)
return result
async def design_progress_status(self):
result = await use_collection("design_progress").aggregate([
{'$match':{'complete_at':{"$exists":0},'parent_id':{"$exists":0}}},
{"$lookup": {
"from": TaskEnv.CLN_PROJECT,
"localField": "project_id",
"foreignField": "_id",
"as": "project"
}},
{"$match": {"project.design.sts": 2}},
{"$group":
{'_id': '$type',
'count': {'$sum': 1},
@@ -428,6 +560,13 @@ class ReportRepository():
async def design_progress_status_state(self):
result = await use_collection("design_progress").aggregate([
{'$match':{'complete_at':{"$exists":0},'parent_id':{"$exists":0}}},
{"$lookup": {
"from": TaskEnv.CLN_PROJECT,
"localField": "project_id",
"foreignField": "_id",
"as": "project"
}},
{"$match": {"project.design.sts": 2}},
{"$group":
{'_id': {'state':"$state",'type':'$type'},
'count': {'$sum': 1},
@@ -532,6 +671,45 @@ class ReportRepository():
res['audit_program'] = result
return res
# فرآیند طراحی گانت چارت
async def design_chart(self, project_id: uuid):
list_design_progress = await use_collection('design_progress').find({"project_id": project_id}).sort('type', 1).to_list(None)
if not list_design_progress:
return {}
list_design_progress_ids = [doc["_id"] for doc in list_design_progress]
list_design_progress_record = await use_collection('design_progress_record').find({"design_progress_id": {"$in":list_design_progress_ids}}).to_list(None)
grouped_records = {}
for i in list_design_progress:
t = i["type"]
st = i.get("sub_type")
if st:
grouped_records.setdefault(t, {})[st] = {
"progress": i,
"records": []
}
else:
grouped_records.setdefault(t, {
"progress": i,
"records": []
})
print(grouped_records)
for item in list_design_progress:
for record in list_design_progress_record:
if record["design_progress_id"] == item['_id']:
if 'sub_type' not in item:
grouped_records[item['type']]["records"].append(record)
else:
grouped_records[item['type']][item['sub_type']]["records"].append(record)
return grouped_records
+2 -1
View File
@@ -18,4 +18,5 @@ from .financial_status import *
from .financial_adjust import *
from .delivery import *
from .report import *
from .excel import *
from .excel import *
from .download import *
+48
View File
@@ -0,0 +1,48 @@
"""
دانلود فایل اکسل تولیدشده از خروجی جدول (Table Engine).
فایل‌ها در پوشه temp پروژه ذخیره می‌شوند و از طریق این endpoint قابل دانلود هستند.
"""
import os
import re
from fastapi import APIRouter, HTTPException
from starlette.responses import FileResponse
from config import conf
from core.app import app
from core.utils.router import generate_router
router = generate_router("download", auth_dependencies=False, version="v2")
_tag_base = ["download"]
_pre = conf.server.prefix_url.replace("v1", "v2")
def _excel_temp_dir():
return os.path.join(os.getcwd(), "temp")
def _safe_excel_filename(name: str) -> bool:
"""فقط نام فایل اکسلی با فرمت uuid.xlsx (۳۲ کاراکتر hex + .xlsx) مجاز است."""
return bool(re.match(r"^[a-f0-9]{32}\.xlsx$", name))
@router.get("/excel/{filename}", tags=_tag_base)
async def download_excel(filename: str):
"""
دانلود فایل اکسل با نام بازگردانده‌شده از API لیست (فیلد path).
مثال: بعد از درخواست خروجی اکسل، در پاسخ path مثلاً «abc123...xlsx» می‌آید؛
برای دانلود: GET /v2/download/excel/abc123....xlsx
"""
if not _safe_excel_filename(filename):
raise HTTPException(status_code=400, detail="نام فایل نامعتبر است.")
file_path = os.path.join(_excel_temp_dir(), filename)
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="فایل یافت نشد یا منقضی شده است.")
return FileResponse(
file_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=filename,
)
app.include_router(router)
+8 -3
View File
@@ -18,9 +18,9 @@ def design():
def monitor():
pass
@router.get('/progress1', tags=_tag_base)
@shrink(repo.addProgress1)
def addProgress1():
@router.get('/progress/madarek', tags=_tag_base)
@shrink(repo.madarek)
def madarek():
pass
@router.get('/progress/physical', tags=_tag_base)
@@ -89,6 +89,11 @@ def addProgressTender():
def changeStatus():
pass
@router.get('/change/design/metri/sts', tags=_tag_base)
@shrink(repo.changeStatusWithMetri)
def changeStatusWithMetri():
pass
@router.get('/progress/monitor/physical', tags=_tag_base)
@shrink(repo.progressMonitorPhysical)
def progressMonitor():
+5
View File
@@ -25,6 +25,11 @@ def create(coding: ThisModel = Body(...)):
def last_record(last:LastRecord):
pass
# @router.get('/progress/rediv', response_model=list[dict], tags=_tag_base, description='پیشرفت فیزیکی')
# @shrink(repo.recalc_medium_progress)
# def recalc_medium_progress():
# pass
@router.get('/list/{_id}', response_model=List[ThisModel], tags=_tag_base, description='پیشرفت فیزیکی')
@shrink(repo.list_by_project)
def list_by_project(_id: UUID):
+10 -1
View File
@@ -3,7 +3,7 @@ from uuid import UUID
from fastapi import Body
from app.models.project import ProjectModel, UpdateSts
from app.models.project import ProjectModel, UpdateSts, ListWithIds
from app.repositories.project import ProjectRepository
from core.app import app
from core.utils.router import generate_router, shrink
@@ -32,6 +32,15 @@ def list_project_design():
def list_project_monitor():
pass
@router.post('/list/ids', response_model=ThisModel, tags=_tag_base)
@shrink(repo.list_project_with_ids)
def list_project_with_ids(inp:ListWithIds):
pass
@router.get('/list/all', response_model=List[ThisModel], tags=_tag_base)
@shrink(repo.list_project_with_select)
def list_project_with_select():
pass
@router.get('/list/project', response_model=List[ThisModel], tags=_tag_base)
@shrink(repo.list_project)
def list_project():
+20 -4
View File
@@ -2,7 +2,7 @@ import uuid
from fastapi import Body
from app.models.report import StatusReport, ReportCountry, DonutList, DelayDonutDetail, PhysicalMonth
from app.models.report import StatusReport, ReportCountry, DonutList, DonutReport, DelayDonutDetail, PhysicalMonth
from app.repositories.report import ReportRepository
from core.app import app
from core.utils.router import generate_router, shrink
@@ -62,15 +62,25 @@ def sum_add_time_end_project():
@shrink(repo.delay_donut_all)
def delay_donut_all():
pass
@router.post('/donut-v2/delay/all', tags=_tag_base)
@shrink(repo.delay_donut_all_v2)
def delay_donut_all_v2(_inp: DonutReport = Body(...)):
pass
#لیست پروژه ها را نمایش میده
@router.get('/donut/delay/{_select}', tags=_tag_base)
@shrink(repo.list_delay_donut)
def list_delay_donut(_select):
pass
#تفکیک پروژه ها بر حسب زیربنا و تفکیک ماهیت پروژه و تفکیک بر حسب نوع پروژه
@router.get('/donut/{_select}', tags=_tag_base)
@router.post('/donut/{_select}', tags=_tag_base)
@shrink(repo.donut)
def donut(_select:int):
def donut(_select: int, _inp: DonutReport = Body(...)):
pass
@router.post('/donut-v2/{_select}', tags=_tag_base)
@shrink(repo.donut_v2)
def donut_v2(_select: int, _inp: DonutReport = Body(...)):
pass
@router.post('/list-donut', tags=_tag_base)
@@ -139,4 +149,10 @@ def status_agreement_progress():
def get_extra_project(project_id: uuid.UUID):
pass
app.include_router(router)
# فرآیند طراحی گانت چارت
@router.get('/design/chart/{project_id}', tags=_tag_base)
@shrink(repo.design_chart)
def design_chart(project_id: uuid.UUID):
pass
app.include_router(router)
+2 -2
View File
@@ -5,8 +5,8 @@ from core.service.mongo_db import use_collection
def get_auth_db():
return conf.server.is_prod and conf.database.get('mongo').production.db or conf.database.get(
'mongo').dev.db
return conf.server.is_prod and conf.database.get('mongo_auth').production.db or conf.database.get(
'mongo_auth').dev.db
def use_auth_collection(collection: str) -> Collection:
+18 -3
View File
@@ -4,7 +4,7 @@ auth:
algorithm: 'HS256'
access_token_expire_minutes: 43201
refresh_token_expire_minutes: 43200
url: 'https://auth.hacoupian.net/v2/auth/validate-token'
url: 'http://pm.khanesaziceco.ir/api/api/me'
cache_prefix_key: 'auth_key_'
cache_ttl: 360
@@ -68,11 +68,11 @@ database:
db: 'omde'
limiter_redis:
url: 'redis://192.168.15.76:6379'
url: 'redis://172.16.1.194:6379'
db: ''
auth_redis:
url: 'redis://192.168.15.231:6379'
url: 'redis://172.16.1.194:6379'
db: '1'
neo4j:
@@ -81,3 +81,18 @@ database:
version: 4
password: 'sample'
user: 'local'
# MySQL (read-only) - جدا از database تا مونگو و بقیه بدون تغییر بمانند
mysql:
dev:
host: '172.16.1.194'
port: 3309
user: 'root'
password: '2TjwtsnYAs'
db: 'doc'
production:
host: '172.16.1.194'
port: 3309
user: 'root'
password: '2TjwtsnYAs'
db: 'doc'
+38 -4
View File
@@ -1,10 +1,10 @@
import os
from pathlib import Path
from typing import Optional, List, Union
from typing import Optional, List, Union, Dict, Any
import pydantic
import yaml
from pydantic import BaseModel
from pydantic import BaseModel, validator
def build_path(path: Union[Path, str], path_is_absolute: bool = False) -> Path:
@@ -73,6 +73,19 @@ class DatabaseModeConfig(BaseModel):
production: Optional[DatabaseConfig]
class MySQLConfig(BaseModel):
host: str
port: int
user: str
password: str
db: str
class MySQLModeConfig(BaseModel):
dev: Optional[MySQLConfig]
production: Optional[MySQLConfig]
class DependencyConfig(BaseModel):
middleware: Optional[List[str]]
routes: Optional[List[str]]
@@ -115,12 +128,33 @@ class CoreConfig(pydantic.BaseModel):
server: Optional[ServerConfig]
auth: Optional[AuthConfig]
dependency: Optional[DependencyConfig]
database: dict[str, Union[DatabaseConfig, DatabaseModeConfig]]
database: Dict[str, Union[DatabaseConfig, DatabaseModeConfig]]
mysql: Optional[MySQLModeConfig] = None
static_files: dict[str, str] = dict()
celery: Optional[CeleryTask]
@validator('database', pre=True)
def parse_database(cls, v: Any) -> Dict[str, Union[DatabaseConfig, DatabaseModeConfig]]:
if not isinstance(v, dict):
return v
result: Dict[str, Union[DatabaseConfig, DatabaseModeConfig]] = {}
for key, val in v.items():
if not isinstance(val, dict):
result[key] = val
continue
if 'dev' in val or 'production' in val:
dev = val.get('dev')
prod = val.get('production')
result[key] = DatabaseModeConfig(
dev=DatabaseConfig(**(dev or {})) if dev else None,
production=DatabaseConfig(**(prod or {})) if prod else None,
)
else:
result[key] = DatabaseConfig(**val)
return result
class Config:
arbitrary_types_allowed = False
arbitrary_types_allowed = True
# def load_modules_config(self):
# # import dynamic routes in the modules
+67
View File
@@ -0,0 +1,67 @@
"""
اتصال MySQL فقط-خواندنی (read-only).
از پول کانکشن استفاده می‌کند و در هر کانکشن SESSION را به حالت فقط-خواندنی تنظیم می‌کند.
"""
from contextlib import asynccontextmanager
from typing import Optional
import aiomysql
from core.utils.mysql import get_mysql_config
_pool: Optional[aiomysql.Pool] = None
_pool_connection_name: Optional[str] = None
async def get_mysql_pool(connection_name: str = None) -> aiomysql.Pool:
"""پول کانکشن MySQL را برمی‌گرداند؛ در اولین فراخوانی ساخته می‌شود."""
global _pool, _pool_connection_name
name = connection_name or "mysql"
if _pool is None or _pool_connection_name != name:
if _pool is not None:
_pool.close()
await _pool.wait_closed()
cfg = get_mysql_config(name)
_pool = await aiomysql.create_pool(
host=cfg["host"],
port=cfg["port"],
user=cfg["user"],
password=cfg["password"],
db=cfg["db"],
charset="utf8mb4",
autocommit=True,
minsize=1,
maxsize=10,
)
_pool_connection_name = name
return _pool
@asynccontextmanager
async def get_mysql_connection(connection_name: str = None):
"""
کانکشن فقط-خواندنی از پول می‌گیرد.
در ابتدا SESSION را به حالت فقط-خواندنی تنظیم می‌کند (برای اطمینان بیشتر).
استفاده:
async with get_mysql_connection() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("SELECT * FROM t LIMIT 10")
rows = await cur.fetchall()
"""
pool = await get_mysql_pool(connection_name)
conn = await pool.acquire()
try:
async with conn.cursor() as cur:
await cur.execute("SET SESSION TRANSACTION READ ONLY")
yield conn
finally:
pool.release(conn)
async def close_mysql_pool():
"""بستن پول (مثلاً در shutdown اپ)."""
global _pool
if _pool is not None:
_pool.close()
await _pool.wait_closed()
_pool = None
+81
View File
@@ -0,0 +1,81 @@
"""
SQLAlchemy async setup برای MySQL (فقط خواندنی).
از asyncmy به عنوان async driver استفاده می‌کند.
"""
from contextlib import asynccontextmanager
from typing import Optional
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
from core.utils.mysql import get_mysql_config
# Base class برای تمام مدل‌های ORM
Base = declarative_base()
_engine = None
_async_session_maker = None
def get_mysql_url(connection_name: str = None) -> str:
"""ساخت URL برای SQLAlchemy از تنظیمات MySQL"""
cfg = get_mysql_config(connection_name)
return f"mysql+asyncmy://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/{cfg['db']}?charset=utf8mb4"
def get_async_engine(connection_name: str = None):
"""برگرداندن async engine (در اولین فراخوانی ساخته می‌شود)"""
global _engine
if _engine is None:
url = get_mysql_url(connection_name)
_engine = create_async_engine(
url,
echo=False, # برای debug می‌تونی True کنی
pool_pre_ping=True, # چک کردن کانکشن قبل از استفاده
pool_size=5,
max_overflow=10,
)
return _engine
def get_async_session_maker(connection_name: str = None):
"""برگرداندن async sessionmaker"""
global _async_session_maker
if _async_session_maker is None:
engine = get_async_engine(connection_name)
_async_session_maker = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
return _async_session_maker
@asynccontextmanager
async def get_mysql_session(connection_name: str = None):
"""
Context manager برای گرفتن async session (فقط خواندنی).
استفاده:
async with get_mysql_session() as session:
result = await session.execute(select(User).where(User.id == 1))
user = result.scalar_one_or_none()
"""
session_maker = get_async_session_maker(connection_name)
async with session_maker() as session:
# تنظیم session به حالت فقط-خواندنی
await session.execute("SET SESSION TRANSACTION READ ONLY")
try:
yield session
finally:
await session.rollback() # برای read-only همیشه rollback می‌کنیم
async def close_mysql_engine():
"""بستن engine (مثلاً در shutdown اپ)."""
global _engine, _async_session_maker
if _engine is not None:
await _engine.dispose()
_engine = None
_async_session_maker = None
+3 -3
View File
@@ -23,9 +23,8 @@ async def validate_token(_input: ValidateTokenInput):
_cache_auth = await redis_get(f'{conf.auth.cache_prefix_key}{_cache_key}', conf.database.get('auth_redis'))
if not _cache_auth:
client = httpx.AsyncClient()
headers = {'Content-Type': 'application/json', }
result = await client.request('POST', conf.auth.url, json=_input.dict(), headers=headers)
headers = {'Content-Type': 'application/json', 'Authorization':'Bearer '+_input.token}
result = await client.request('GET', conf.auth.url, json=_input.dict(), headers=headers)
export = result.text
if 'application/json' in result.headers['content-type']:
export = result.json()
@@ -35,6 +34,7 @@ async def validate_token(_input: ValidateTokenInput):
if 'v2' in conf.auth.url:
export = export.get('data')
print(export)
await redis_set(f'{conf.auth.cache_prefix_key}{_cache_key}', export, conf.database.get('auth_redis'),
ex=conf.auth.cache_ttl)
+3 -3
View File
@@ -8,12 +8,12 @@ from core.models.base import BaseModel
class UserModel(BaseModel):
id: Optional[Union[uuid.UUID, str]] = Field(alias='_id')
id: Optional[Union[uuid.UUID, str, int]] = Field(alias='_id')
username: Optional[str]
mobile: Optional[str]
name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
fname: Optional[str]
lname: Optional[str]
is_su: Optional[bool]
unit: Optional[str]
avatar: Optional[str]
+25
View File
@@ -0,0 +1,25 @@
"""
تنظیمات اتصال MySQL (فقط خواندنی).
از بخش جداگانه mysql در config.yml (هم‌سطح با database) استفاده می‌کند.
"""
from config import conf
def get_mysql_config(connection_name: str = None):
"""
بر اساس محیط (dev/production) تنظیمات MySQL را برمی‌گرداند.
Returns: dict با کلیدهای host, port, user, password, db
"""
if not conf.mysql:
raise ValueError("تنظیمات mysql در config.yml یافت نشد.")
env = "production" if conf.server.is_prod else "dev"
mode = getattr(conf.mysql, env, None) or getattr(conf.mysql, "dev", None)
if not mode:
raise ValueError(f"تنظیمات mysql.{env} در config.yml یافت نشد.")
return {
"host": getattr(mode, "host", "127.0.0.1"),
"port": int(getattr(mode, "port", 3306)),
"user": getattr(mode, "user", ""),
"password": getattr(mode, "password", ""),
"db": getattr(mode, "db", ""),
}
+1
View File
@@ -10,6 +10,7 @@ class TableEngineInputType(GenericModel, Generic[T]):
o: Optional[dict] = {}
p: Optional[dict] = {}
s: Optional[dict] = {}
e: Optional[dict] = {}
class TableEngineExport(GenericModel, Generic[T]):
+10 -3
View File
@@ -10,7 +10,8 @@ from .utils.Models import TableEngineExport
class NewTableEngine:
def __init__(self, query_class, model, inputs, mode, engine: AbstractEngine, extra_filter,
default_sort_by, joins, search_cols, options=None, soft_deleted=False, unselect={}, return_query=False):
default_sort_by, joins, search_cols, options=None, soft_deleted=False, unselect={}, return_query=False,
collation={'locale': 'fa'}):
self.model = model
self.query_class = query_class
self.mode = mode
@@ -29,6 +30,7 @@ class NewTableEngine:
self.search_cols = search_cols
self.unselect = unselect
self.return_query = return_query
self.collation = collation
self.engine = engine(self)
@staticmethod
@@ -38,10 +40,12 @@ class NewTableEngine:
mode: WorkingMode = WorkingMode.Development,
extra_filter=None,
soft_deleted=False,
default_sort_by=None, joins=None, search_cols=[] ,unselect={}, return_query=False):
default_sort_by=None, joins=None, search_cols=[] ,unselect={}, return_query=False,
collation={'locale': 'fa'}):
return NewTableEngine(query_class, model, inputs, mode, engine=MongoEngine, extra_filter=extra_filter,
default_sort_by=default_sort_by, joins=joins, search_cols=search_cols,
soft_deleted=soft_deleted,unselect=unselect, return_query=return_query)
soft_deleted=soft_deleted,unselect=unselect, return_query=return_query,
collation=collation)
@staticmethod
def mssql(query_class, model, inputs, mode: WorkingMode = WorkingMode.Development, extra_filter=None,
@@ -62,6 +66,9 @@ class NewTableEngine:
if self.return_query:
query = await self.exe()
return query
if self.input.e and self.input.e.ot:
query = await self.exe()
return query
rows, count = await self.exe()
self.input.extra_filter = None
del self.engine
+1
View File
@@ -17,6 +17,7 @@ class AbstractEngineClass(abc.ABC):
self.soft_deleted = table_engine.soft_deleted
self.unselect = table_engine.unselect
self.return_query = table_engine.return_query
self.collation = {'locale': 'fa'}
@abc.abstractmethod
def exe(self):
+190 -16
View File
@@ -2,6 +2,13 @@ import datetime
import uuid
from typing import Union, List
from uuid import UUID
import os
import xlsxwriter
import tempfile
import string
import copy
import jdatetime
# import dateutil.parser
from celery import shared_task
@@ -9,6 +16,8 @@ from celery import shared_task
from .AbstractEngine import AbstractEngineClass
from ..utils.Models import FilterItem, FilterScope, OutPutType
from ..utils.excel_value_maps import EXCEL_VALUE_MAP_REGISTRY
from app.repositories.mysql_const_repo import MySQLConstRepository
# def zero_time_data(date, is_max=False):
@@ -31,25 +40,30 @@ class MongoEngine(AbstractEngineClass):
rows = []
stack = []
filters = {}
_const_cache: dict = None # کش const ها از MySQL
async def exe(self):
_f = self.filters = self.set_filters()
print("_f ====>", _f)
if self.unselect:
tmp = self.query_class.find(_f, self.unselect)
else:
tmp = self.query_class.find(_f)
sort_by = self.set_sort()
if isinstance(sort_by, dict):
tmp = tmp.sort(**sort_by)
else:
tmp = tmp.sort(sort_by)
self.rows = tmp.limit(
self.input.get_limit()).skip(self.input.skip())
is_natural = isinstance(sort_by, dict) and sort_by.get('key_or_list') == '$natural'
if getattr(self, 'collation', None) and not is_natural:
tmp = tmp.collation(self.collation)
if self.input.e and self.input.e.ot:
self.input.e.d = self.generate_output(self.query_class, _f)
res = await tmp.to_list(None)
self.input.e.d = await self.generate_output(res)
return {'query': _f, 'sort': self.set_sort(), 'path': self.input.e.d}
self.rows = tmp.limit(self.input.get_limit()).skip(self.input.skip())
if self.return_query:
return {'query':_f, 'sort':self.set_sort(), 'limit':self.input.get_limit(), 'page':self.input.skip()}
return [self.model(**document) async for document in self.rows], await self.count()
@@ -249,15 +263,11 @@ class MongoEngine(AbstractEngineClass):
def set_joins(self):
pass
def generate_output(self, query, _f):
print(_f.__dict__)
return ''
async def generate_output(self, _f):
print(self.input.e.ot)
if self.input.e.ot == OutPutType.EXEL:
file_name = str(uuid.uuid4().hex) + '.xlsx'
return _f.dict()
MongoEngine.output_generator(str(query).replace('false', '0'), params,
str(self.query_class.bind.url), file_name=file_name, cols=self.input.e.c)
return file_name
return await self.output_generator(_f, file_name=file_name, cols=self.input.e.c)
elif self.input.e.ot == OutPutType.JSON:
pass
elif self.input.e.ot == OutPutType.PDF:
@@ -265,7 +275,171 @@ class MongoEngine(AbstractEngineClass):
else:
raise Exception("Output type not supported.")
@staticmethod
@shared_task(name='output_generator_task_mongo')
def output_generator(query, params, url, file_name, cols):
pass
def _excel_temp_dir(self):
"""پوشه temp پروژه برای ذخیره فایل‌های اکسل (همان مسیری که برای دانلود سرو می‌شود)."""
temp_dir = os.path.join(os.getcwd(), 'temp')
os.makedirs(temp_dir, exist_ok=True)
return temp_dir
def _expand_headers_with_id_fields(self, cols: dict) -> list:
"""
اگر design.nature در فیلدها باشد، design.nature_id هم اضافه می‌شود.
اگر design.budget در فیلدها باشد، design.budget_id هم اضافه می‌شود.
"""
headers = list(cols.keys())
expanded = []
pair_rules = [
('design.nature', 'design.nature_id', 'نام ماهیت'),
('design.budget', 'design.budget_id', 'نام دسته بودجه'),
]
for h in headers:
expanded.append(h)
for field, id_field, default_title in pair_rules:
if h == field and id_field not in headers and id_field not in expanded:
expanded.append(id_field)
cols[id_field] = cols.get(field, default_title) + ' (نام)'
break
return expanded
async def output_generator(self, query, file_name, cols):
datas = query
file_dir = os.path.join(self._excel_temp_dir(), file_name)
head_cols = list(str(string.ascii_uppercase))
headers = self._expand_headers_with_id_fields(cols)
workbook = xlsxwriter.Workbook(file_dir)
worksheet = workbook.add_worksheet()
# نوشتن هدرها
bold_format = workbook.add_format({'bold': True})
# نوشتن هدرها
for col_index, header in enumerate(headers):
worksheet.write(0, col_index, cols[header], bold_format)
# ساختن value_maps از vk (نام مپ) با استفاده از رجیستری
value_maps = self._build_value_maps()
# بارگذاری const ها برای فیلدهای خاص
const_maps = await self._load_const_maps(headers)
for row_index, item in enumerate(datas, start=1):
for col_index, col in enumerate(headers):
val = self.get_nested_value(item, col)
val = self._apply_value_map(col, val, value_maps)
val = await self._apply_const_map(col, val, const_maps)
worksheet.write(row_index, col_index, val)
workbook.close()
return file_name
def _build_value_maps(self):
"""اگر کاربر در e.vk برای ستونی کلید مپ داده (مثلاً design_sts)، مپ از رجیستری برمی‌گردد."""
value_maps = {}
vk = getattr(self.input.e, "vk", None) or {}
if not isinstance(vk, dict):
return value_maps
for col_key, map_name in vk.items():
if map_name and map_name in EXCEL_VALUE_MAP_REGISTRY:
value_maps[col_key] = EXCEL_VALUE_MAP_REGISTRY[map_name]
return value_maps
def _apply_value_map(self, col_key: str, val, value_maps: dict):
"""اگر برای این ستون مپ تعریف شده، مقدار را به متن متناظر تبدیل می‌کند."""
if not value_maps or col_key not in value_maps:
return val
mapping = value_maps[col_key]
if not isinstance(mapping, dict):
return val
if val is None:
return mapping.get(None, val)
key = val if isinstance(val, (int, float)) else (int(val) if isinstance(val, str) and str(val).strip().isdigit() else val)
if key in mapping:
return mapping[key]
if isinstance(key, int) and str(key) in mapping:
return mapping[str(key)]
return val
async def _load_const_maps(self, headers: List[str]) -> dict:
"""
شناسایی فیلدهای خاص (مثلاً nature_id, type_id, budget_id) و بارگذاری const ها از MySQL.
Returns: dict به صورت {col_key: {id: title_fa}} مثلاً {"design.nature_id": {2: "طبیعت 2", ...}}
"""
# لیست فیلدهای خاص که باید از const خوانده شوند
const_field_names = ['nature_id', 'type_id', 'budget_id'] # می‌تونی اضافه کنی
const_maps = {}
const_keys_to_load = set()
const_column_map = {} # {col_key: const_key} مثلاً {"design.nature_id": "nature"}
# شناسایی ستون‌هایی که باید const بخونن
for col_key in headers:
col_name = col_key.split('.')[-1] # آخرین بخش (nature_id)
if col_name in const_field_names:
const_key = col_name.replace('_id', '') # nature_id -> nature
const_keys_to_load.add(const_key)
const_column_map[col_key] = const_key
# بارگذاری const ها از MySQL
if const_keys_to_load:
repo = MySQLConstRepository()
for col_key, const_key in const_column_map.items():
consts_for_key = await repo.get_by_key(const_key)
if consts_for_key:
const_maps[col_key] = consts_for_key
return const_maps
async def _apply_const_map(self, col_key: str, val, const_maps: dict):
"""اگر این ستون در const_maps باشد، id را به title_fa تبدیل می‌کند."""
if not const_maps or col_key not in const_maps:
return val
mapping = const_maps[col_key]
if not isinstance(mapping, dict):
return val
if val is None:
return mapping.get(None, val)
# تبدیل val به int برای جستجو در mapping
key = val if isinstance(val, (int, float)) else (int(val) if isinstance(val, str) and str(val).strip().isdigit() else val)
if key in mapping:
return mapping[key]
if isinstance(key, int) and str(key) in mapping:
return mapping[str(key)]
return val
def get_nested_value(self, data, path, default='-'):
"""
دریافت مقدار از دیکشنری تو در تو با مسیر dot notation
مثال: path = "design.title"
"""
if not isinstance(path, str):
return default
keys = path.split('.')
val = data
for key in keys:
if isinstance(val, dict):
val = val.get(key, default)
else:
return default
if val is None:
return default
# تبدیل datetime و UUID به رشته برای اکسل
if isinstance(val, datetime.datetime):
return self.create_date(val)
if isinstance(val, uuid.UUID):
return str(val)
return val
def create_date(self, date):
shamsi_dt = jdatetime.datetime.fromgregorian(datetime=date)
return shamsi_dt.strftime("%Y/%m/%d - %H:%M:%S")
+2 -1
View File
@@ -35,7 +35,7 @@ class Cr(str, Enum):
class OutPutType(str, Enum):
JSON = 'json'
EXEL = 'exel'
EXEL = 'excel'
PDF = 'pdf'
@@ -81,6 +81,7 @@ class ExportModel(BaseModel):
ot: Optional[OutPutType]
c: Optional[dict]
d: Optional[str]
vk: Optional[dict] = None
class SortModel(BaseModel):
+25
View File
@@ -0,0 +1,25 @@
"""
رجیستری مپ‌های ازپیش‌تعریف‌شده برای خروجی اکسل.
کلاینت فقط کلید (مثلاً design_sts) را در e.vk می‌فرستد تا به‌جای عدد، متن در اکسل بیاید.
"""
EXCEL_VALUE_MAP_REGISTRY = {
"design_sts": {
1: "شروع نشده",
2: "جاری",
3: "متوقف",
4: "خاتمه یافته",
},
"monitor_sts": {
1: "شروع نشده",
2: "جاری",
3: "متوقف",
4: "تحویل موقت",
5: "خاتمه یافته",
},
"sts": {
1: "جاری",
2: "حذف شده",
},
}
+2
View File
@@ -1,5 +1,7 @@
aiofiles==22.1.0
aiomysql==0.2.0
aioredis==2.0.1
asyncmy==0.2.9
amqp==5.1.1
annotated-types==0.6.0
anyio==3.6.2