refactor(gerp): 应用按子应用目录规约重组,清单外置并拆分路由
- 新增 gvsds/apps/gerp/apps/<appid>/:talk/mailbox/gitea/oa/ehrm/dingcalc_finance/ product_order/shop/store_value/wallet/enterprise,各应用自带 models/manager/views/urls 与 apps.gerp_manifests(JSON 清单,$schema 指向 apps/gerp_manifests.schema.json) - 删除 app_manifests.py 内联 _LEGACY_MANIFESTS,改为扫描各应用清单文件(未知键/缺必填键即判非法并跳过) - 路由按应用拆:/API/Mail/、/API/Gitea/、/API/Oa/ 由扁平直挂改为 include 子应用 urls.py (reverse() 断言 19 个 name 的路径逐字不变) - AppConfig 移至 gerp_app.py 并改显式点分路径注册,规避 apps/ 目录与 Django <app>.apps 同名冲突 - gerp/models.py 裁剪已搬迁模型(保留 _NewUuidBytes 与平台模型),修复 views/__init__.py 与 oidc.py 导入 - AssetDownloadView:优先按 FileAsset.StoragePath 定位并回退 ./resource 旧布局,修上传成功却下载 404 - 同批落地 Talk 应用后端(app 已迁入 apps/talk,gerp.0046 状态认领迁移) - 注意:app.ini 被 .gitignore 忽略,INSTALLED_APPS 需在部署环境手工同步 gvsds.apps.gerp.gerp_app.GerpConfig
This commit is contained in:
1 parent
00b8e32b22
commit
fbf1bb7d6e
54 files changed
+3129
-910
No files matched your search
+65
-8
@@ -20,21 +20,78 @@ class IPInfoView(APIView):
|
||||
|
||||
|
||||
class AssetDownloadView(View):
|
||||
"""文件资产下载(按 SHA1)
|
||||
|
||||
取物理文件的顺序(先新后旧):
|
||||
1. FileAsset.StoragePath —— 当前上传链路(/API/Asset/Upload*)的落地位置
|
||||
(MEDIA_ROOT/assets/<uploadId>_<原始文件名>)
|
||||
2. ./resource/<aa>/<bb>/<cc>/<sha1> —— Flask 期遗留布局,保持向后兼容
|
||||
|
||||
历史问题:上传写 MEDIA_ROOT/assets,而本视图原先只读 ./resource 布局,
|
||||
导致上传成功却下载 404(资产管理页与 Talk 图片/文件消息均受影响)。
|
||||
"""
|
||||
|
||||
def get(self, request, sha1):
|
||||
# 验证 sha1 参数格式,防止路径遍历
|
||||
if not re.match(r'^[0-9a-fA-F]{40}$', sha1):
|
||||
return HttpResponse('Invalid hash', status=400)
|
||||
|
||||
resource_dir = Path('./resource').resolve()
|
||||
file_path = (resource_dir / sha1[0:2] / sha1[2:4] / sha1[4:6] / sha1).resolve()
|
||||
resolved = _ResolveAssetFile(sha1)
|
||||
if resolved is None:
|
||||
return JsonResponse({'code': 404, 'msg': '文件不存在'}, status=404)
|
||||
|
||||
# 确保解析后的路径在允许的目录内
|
||||
if not str(file_path).startswith(str(resource_dir)):
|
||||
return HttpResponse('Invalid path', status=400)
|
||||
file_path, file_name = resolved
|
||||
return FileResponse(open(file_path, 'rb'), as_attachment=True, filename=file_name or sha1)
|
||||
|
||||
if file_path.exists():
|
||||
return FileResponse(open(file_path, 'rb'), as_attachment=True, filename=sha1)
|
||||
return JsonResponse({'code': 404, 'msg': '文件不存在'}, status=404)
|
||||
|
||||
#: 允许下载的目录白名单(解析后必须落在其中,防路径遍历)
|
||||
def _ResolveAssetFile(sha1):
|
||||
"""解析资产物理文件:返回 (Path, 下载文件名);找不到返回 None
|
||||
|
||||
先在 FileAsset 表按 SHA1 查 StoragePath(并校验路径落在允许目录内),
|
||||
再回退到 ./resource 分段布局。
|
||||
"""
|
||||
from django.conf import settings as _settings
|
||||
|
||||
media_root = Path(getattr(_settings, 'MEDIA_ROOT', '.')).resolve()
|
||||
resource_root = Path('./resource').resolve()
|
||||
allowed_roots = (media_root, resource_root)
|
||||
|
||||
def _InAllowed(p):
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return resolved
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
# 1) 当前上传链路:FileAsset.StoragePath
|
||||
try:
|
||||
from gvsds.apps.gerp.models import FileAsset
|
||||
|
||||
asset = FileAsset.objects.filter(FileSHA1=sha1).first()
|
||||
if asset and asset.StoragePath:
|
||||
stored = Path(asset.StoragePath)
|
||||
for candidate in (stored, (media_root / str(asset.StoragePath).lstrip('/\\'))):
|
||||
safe = _InAllowed(candidate)
|
||||
if safe is not None and safe.is_file():
|
||||
return safe, asset.FileName or sha1
|
||||
except Exception:
|
||||
# 查库失败不能影响旧布局回退(保持下载可用)
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning('资产按 StoragePath 定位失败:%s', sha1, exc_info=True)
|
||||
|
||||
# 2) Flask 期遗留布局:./resource/<aa>/<bb>/<cc>/<sha1>
|
||||
legacy = _InAllowed(resource_root / sha1[0:2] / sha1[2:4] / sha1[4:6] / sha1)
|
||||
if legacy is not None and legacy.is_file():
|
||||
return legacy, sha1
|
||||
return None
|
||||
|
||||
|
||||
class HealthCheckView(APIView):
|
||||
|
||||
+182
-388
@@ -19,8 +19,11 @@
|
||||
合并本地 + 远端清单统一下发。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from gvsdsdk.auth.managers import AppManager as SdkAppManager, MenuManager, PositionManager
|
||||
@@ -29,6 +32,11 @@ from gvsdsdk.models import Menu as SdkMenu
|
||||
|
||||
from .models import App
|
||||
|
||||
# 说明:各子应用(gvsds/apps/gerp/apps/<appenglishid>/)的模型由 gerp/models.py 在
|
||||
# 模型加载阶段统一导入(ImportSubAppModels),本模块不再触发导入。
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppMenuSpec:
|
||||
@@ -118,395 +126,181 @@ class AppManifest:
|
||||
Source: str = 'local' # local=本地清单 / msync=远端注册
|
||||
|
||||
|
||||
# ==================== 内置应用清单(local) ====================
|
||||
# 现状 6 个应用均为企业功能(enterprise),菜单挂载点保持与数据库现状一致。
|
||||
# ==================== 清单文件扫描(新目录规约) ====================
|
||||
|
||||
APP_MANIFESTS: List[AppManifest] = [
|
||||
AppManifest(
|
||||
AppCode='mailbox',
|
||||
AppName='企业邮箱',
|
||||
AppIcon='Message',
|
||||
AppDesc='企业级邮件服务(Stalwart),支持多邮箱申请与安全浏览',
|
||||
AppType='enterprise',
|
||||
AppSort=1,
|
||||
FrontendRoute='/mail/mailbox',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='mailbox', MenuTitle='我的邮箱',
|
||||
MenuLink='pages/mail/mailbox.html', MenuIcon='Message',
|
||||
MenuSort=3, ParentMenuName='myself',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='mailbox-manage', MenuTitle='邮箱管理',
|
||||
MenuLink='pages/mail/admin.html', MenuIcon='Message',
|
||||
MenuSort=6, ParentMenuName='company',
|
||||
),
|
||||
],
|
||||
ConfigKeys=[
|
||||
AppConfigSpec(Key='SMTP_HOST', Label='SMTP 服务器', Placeholder='如 smtp.gvsds.com'),
|
||||
AppConfigSpec(Key='SMTP_PORT', Label='SMTP 端口', Default='465'),
|
||||
AppConfigSpec(Key='DOMAIN', Label='邮箱域名', Placeholder='如 gvsds.com'),
|
||||
AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='gitea',
|
||||
AppName='代码托管 Gitea',
|
||||
AppIcon='Connection',
|
||||
AppDesc='企业代码托管平台,支持免密登录与组织协作',
|
||||
AppType='enterprise',
|
||||
AppSort=2,
|
||||
FrontendRoute='/gitea/mine',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='gitea-mine', MenuTitle='我的 Gitea',
|
||||
MenuLink='pages/gitea/mine.html', MenuIcon='Connection',
|
||||
MenuSort=4, ParentMenuName='myself',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='gitea-admin', MenuTitle='Gitea 账号管理',
|
||||
MenuLink='pages/gitea/admin.html', MenuIcon='Connection',
|
||||
MenuSort=7, ParentMenuName='company',
|
||||
),
|
||||
],
|
||||
ConfigKeys=[
|
||||
AppConfigSpec(Key='ORG_NAME', Label='组织名', Placeholder='如 GVSDS'),
|
||||
AppConfigSpec(Key='DOMAIN', Label='托管域名', Placeholder='如 git.gvsds.com'),
|
||||
AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='shop',
|
||||
AppName='枢机协律-商店',
|
||||
AppIcon='ShoppingCart',
|
||||
AppDesc='枢机协律-商店:企业内部商店与商城订单管理',
|
||||
AppType='enterprise',
|
||||
AppSort=3,
|
||||
FrontendRoute='/private/CreateE/shop',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/shop.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-shop', MenuTitle='商店',
|
||||
MenuLink='pages/private/CreateE/shop.html',
|
||||
MenuIcon='ShoppingCart', MenuSort=4, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='store-value',
|
||||
AppName='枢机协律-储值',
|
||||
AppIcon='Wallet',
|
||||
AppDesc='枢机协律-储值:储值卡充值与管理',
|
||||
AppType='enterprise',
|
||||
AppSort=4,
|
||||
FrontendRoute='/private/CreateE/StoreValue',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/StoreValue.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-store-value', MenuTitle='储值查看',
|
||||
MenuLink='pages/private/CreateE/StoreValue.html',
|
||||
MenuIcon='Wallet', MenuSort=3, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='wallet',
|
||||
AppName='枢机协律-支付系统',
|
||||
AppIcon='Wallet',
|
||||
AppDesc='枢机协律-支付系统:个人钱包账户与支付',
|
||||
AppType='enterprise',
|
||||
AppSort=5,
|
||||
FrontendRoute='/private/CreateE/wallet',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/wallet.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-wallet', MenuTitle='钱包',
|
||||
MenuLink='pages/private/CreateE/wallet.html',
|
||||
MenuIcon='Wallet', MenuSort=0, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='enterprise',
|
||||
AppName='枢机协律-模拟企业',
|
||||
AppIcon='OfficeBuilding',
|
||||
AppDesc='枢机协律-模拟企业:企业信息管理(工商注册与企业详情)',
|
||||
AppType='enterprise',
|
||||
AppSort=6,
|
||||
FrontendRoute='/private/CreateE/enterprise',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/enterprise.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-enterprise', MenuTitle='企业管理',
|
||||
MenuLink='pages/private/CreateE/enterprise.html',
|
||||
MenuIcon='OfficeBuilding', MenuSort=1, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='dingcalc-finance',
|
||||
AppName='鼎算财务',
|
||||
AppIcon='Money',
|
||||
AppDesc='鼎算财务:企业财务管理(凭证/出纳/会计/报表/报销/应收应付/预算)',
|
||||
AppType='enterprise',
|
||||
AppSort=7,
|
||||
RequirePositionBinding=1,
|
||||
FrontendRoute='/finance/voucher',
|
||||
FrontendType='vue',
|
||||
LegacySrc='',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='finance-voucher', MenuTitle='凭证管理',
|
||||
MenuLink='/finance/voucher',
|
||||
MenuIcon='Tickets', MenuSort=1, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-cashier', MenuTitle='出纳管理',
|
||||
MenuLink='/finance/cashier',
|
||||
MenuIcon='Coin', MenuSort=2, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-ledger', MenuTitle='会计账簿',
|
||||
MenuLink='/finance/ledger',
|
||||
MenuIcon='Notebook', MenuSort=3, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-report', MenuTitle='财务报表',
|
||||
MenuLink='/finance/report',
|
||||
MenuIcon='DataAnalysis', MenuSort=4, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-reimburse', MenuTitle='费用报销',
|
||||
MenuLink='/finance/reimburse',
|
||||
MenuIcon='Money', MenuSort=5, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-arap', MenuTitle='应收应付',
|
||||
MenuLink='/finance/arap',
|
||||
MenuIcon='Refresh', MenuSort=6, ParentMenuName='finance',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='finance-budget', MenuTitle='预算管理',
|
||||
MenuLink='/finance/budget',
|
||||
MenuIcon='PieChart', MenuSort=7, ParentMenuName='finance',
|
||||
),
|
||||
],
|
||||
VirtualPositions=[
|
||||
VirtualPositionSpec(
|
||||
Code='finance-clerk', Name='财务专员',
|
||||
MenuNames=['finance-voucher', 'finance-reimburse', 'finance-arap'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='cashier', Name='出纳',
|
||||
MenuNames=['finance-cashier', 'finance-reimburse'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='accountant', Name='会计',
|
||||
MenuNames=['finance-voucher', 'finance-ledger', 'finance-arap'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='finance-supervisor', Name='财务主管',
|
||||
MenuNames=['finance-voucher', 'finance-report', 'finance-budget'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='finance-manager', Name='财务经理',
|
||||
MenuNames=[
|
||||
'finance-voucher', 'finance-cashier', 'finance-ledger',
|
||||
'finance-report', 'finance-reimburse', 'finance-arap', 'finance-budget',
|
||||
],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='finance-director', Name='财务总监',
|
||||
MenuNames=[
|
||||
'finance-voucher', 'finance-cashier', 'finance-ledger',
|
||||
'finance-report', 'finance-reimburse', 'finance-arap', 'finance-budget',
|
||||
],
|
||||
),
|
||||
],
|
||||
PositionTemplates=[
|
||||
PositionTemplateSpec(
|
||||
CategoryName='财务',
|
||||
Positions=['财务专员', '出纳', '会计', '财务主管', '财务经理', '财务总监'],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='product-order',
|
||||
AppName='产品与订单',
|
||||
AppIcon='Goods',
|
||||
AppDesc='产品与订单:产品模板管理与销售订单(原"业务管理"迁移)',
|
||||
AppType='enterprise',
|
||||
AppSort=8,
|
||||
RequirePositionBinding=1,
|
||||
FrontendRoute='/inventory/products',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='product-items', MenuTitle='产品管理',
|
||||
MenuLink='/inventory/products',
|
||||
MenuIcon='Goods', MenuSort=1, ParentMenuName='product-order',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='product-orders', MenuTitle='销售订单',
|
||||
MenuLink='/sales/orders',
|
||||
MenuIcon='ShoppingCart', MenuSort=2, ParentMenuName='product-order',
|
||||
),
|
||||
],
|
||||
VirtualPositions=[
|
||||
VirtualPositionSpec(
|
||||
Code='product-specialist', Name='产品专员',
|
||||
MenuNames=['product-items'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='order-clerk', Name='订单专员',
|
||||
MenuNames=['product-orders'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='business-supervisor', Name='业务主管',
|
||||
MenuNames=['product-items', 'product-orders'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='business-manager', Name='业务经理',
|
||||
MenuNames=['product-items', 'product-orders'],
|
||||
),
|
||||
],
|
||||
PositionTemplates=[
|
||||
PositionTemplateSpec(
|
||||
CategoryName='业务',
|
||||
Positions=['产品专员', '订单专员', '业务主管', '业务经理'],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='ehrm',
|
||||
AppName='人力资源管理扩展',
|
||||
AppIcon='UserFilled',
|
||||
AppDesc='eHRM:员工档案、考核与绩效管理(与用户详情页联动)',
|
||||
AppType='enterprise',
|
||||
AppSort=9,
|
||||
RequirePositionBinding=1,
|
||||
FrontendRoute='/ehrm/profile',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='ehrm-profile', MenuTitle='档案管理',
|
||||
MenuLink='/ehrm/profile',
|
||||
MenuIcon='Document', MenuSort=1, ParentMenuName='ehrm',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='ehrm-assessment', MenuTitle='考核管理',
|
||||
MenuLink='/ehrm/assessment',
|
||||
MenuIcon='Aim', MenuSort=2, ParentMenuName='ehrm',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='ehrm-performance', MenuTitle='绩效管理',
|
||||
MenuLink='/ehrm/performance',
|
||||
MenuIcon='TrendCharts', MenuSort=3, ParentMenuName='ehrm',
|
||||
),
|
||||
],
|
||||
VirtualPositions=[
|
||||
VirtualPositionSpec(
|
||||
Code='hr-assistant', Name='人事专员',
|
||||
MenuNames=['ehrm-profile'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='assessment-clerk', Name='考核专员',
|
||||
MenuNames=['ehrm-assessment'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='performance-clerk', Name='绩效专员',
|
||||
MenuNames=['ehrm-performance'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='hr-supervisor', Name='人事主管',
|
||||
MenuNames=['ehrm-profile', 'ehrm-assessment', 'ehrm-performance'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='hr-manager', Name='人力资源经理',
|
||||
MenuNames=['ehrm-profile', 'ehrm-assessment', 'ehrm-performance'],
|
||||
),
|
||||
],
|
||||
PositionTemplates=[
|
||||
PositionTemplateSpec(
|
||||
CategoryName='人事',
|
||||
Positions=['人事专员', '考核专员', '绩效专员', '人事主管', '人力资源经理'],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='oa',
|
||||
AppName='办公自动化流程',
|
||||
AppIcon='Files',
|
||||
AppDesc='OA 办公自动化:请假/出差申请、会议管理、公告通知与工作汇报',
|
||||
AppType='enterprise',
|
||||
AppSort=10,
|
||||
RequirePositionBinding=1,
|
||||
FrontendRoute='/oa/leave',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='oa-approval', MenuTitle='审批',
|
||||
MenuLink='/oa/approval',
|
||||
MenuIcon='Stamp', MenuSort=0, ParentMenuName='oa',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='oa-leave', MenuTitle='请假申请',
|
||||
MenuLink='/oa/leave',
|
||||
MenuIcon='Calendar', MenuSort=1, ParentMenuName='oa',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='oa-travel', MenuTitle='出差申请',
|
||||
MenuLink='/oa/travel',
|
||||
MenuIcon='Promotion', MenuSort=2, ParentMenuName='oa',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='oa-meeting', MenuTitle='会议管理',
|
||||
MenuLink='/oa/meeting',
|
||||
MenuIcon='OfficeBuilding', MenuSort=3, ParentMenuName='oa',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='oa-announcement', MenuTitle='公告通知',
|
||||
MenuLink='/oa/announcement',
|
||||
MenuIcon='Bell', MenuSort=4, ParentMenuName='oa',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='oa-report', MenuTitle='工作汇报',
|
||||
MenuLink='/oa/report',
|
||||
MenuIcon='Document', MenuSort=5, ParentMenuName='oa',
|
||||
),
|
||||
],
|
||||
VirtualPositions=[
|
||||
VirtualPositionSpec(
|
||||
Code='oa-staff', Name='普通员工',
|
||||
MenuNames=['oa-leave'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='oa-clerk', Name='行政专员',
|
||||
MenuNames=['oa-approval', 'oa-leave', 'oa-travel', 'oa-meeting', 'oa-report'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='oa-supervisor', Name='行政主管',
|
||||
MenuNames=['oa-approval', 'oa-leave', 'oa-travel', 'oa-meeting', 'oa-announcement', 'oa-report'],
|
||||
),
|
||||
VirtualPositionSpec(
|
||||
Code='oa-manager', Name='行政经理',
|
||||
MenuNames=['oa-approval', 'oa-leave', 'oa-travel', 'oa-meeting', 'oa-announcement', 'oa-report'],
|
||||
),
|
||||
],
|
||||
PositionTemplates=[
|
||||
PositionTemplateSpec(
|
||||
CategoryName='行政',
|
||||
Positions=['普通员工', '行政专员', '行政主管', '行政经理'],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
#: 子应用清单文件名(JSON,无扩展名),位于 apps/<appenglishid>/ 下
|
||||
MANIFEST_FILE_NAME = 'apps.gerp_manifests'
|
||||
|
||||
#: 子应用根目录:gvsds/apps/gerp/apps/
|
||||
_SUB_APPS_DIR = Path(__file__).resolve().parent / 'apps'
|
||||
|
||||
#: 清单允许的顶层键(与 AppManifest 数据类一一对应,防止拼写错误静默失效)
|
||||
_MANIFEST_KEYS = {
|
||||
'$schema', 'AppCode', 'AppName', 'AppIcon', 'AppDesc', 'AppType', 'AuthRequired',
|
||||
'RequirePositionBinding', 'AppSort', 'DefaultPerms', 'FrontendRoute', 'FrontendType',
|
||||
'LegacySrc', 'Menus', 'ConfigKeys', 'VirtualPositions', 'PositionTemplates',
|
||||
}
|
||||
_MENU_KEYS = {
|
||||
'MenuName', 'MenuTitle', 'MenuLink', 'MenuIcon', 'MenuSort',
|
||||
'PermissionLevel', 'PermissionAttr', 'ParentMenuName',
|
||||
}
|
||||
_CONFIG_KEYS = {'Key', 'Label', 'Default', 'Secret', 'Placeholder'}
|
||||
_VPOS_KEYS = {'Code', 'Name', 'MenuNames'}
|
||||
_PTPL_KEYS = {'CategoryName', 'Positions'}
|
||||
|
||||
|
||||
def _ManifestFiles() -> List[Path]:
|
||||
"""扫描全部子应用清单文件(apps/<appenglishid>/apps.gerp_manifests)"""
|
||||
if not _SUB_APPS_DIR.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
item / MANIFEST_FILE_NAME
|
||||
for item in _SUB_APPS_DIR.iterdir()
|
||||
if item.is_dir() and (item / MANIFEST_FILE_NAME).is_file()
|
||||
)
|
||||
|
||||
|
||||
def _TryKeys(raw: dict, allowed: set, path: Path, where: str) -> bool:
|
||||
"""校验未知键(返回 False 表示清单不合法)"""
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
logger.warning('应用清单 %s 的 %s 含未知字段 %s', path, where, sorted(unknown))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ToManifest(raw: dict, path: Path) -> Optional[AppManifest]:
|
||||
"""JSON dict → AppManifest(字段缺失用数据类默认值;结构非法返回 None)
|
||||
|
||||
采用手工校验(不引入 jsonschema 运行时依赖):顶层/菜单/配置键的键名与
|
||||
基本类型必须正确,未知键视为错误——避免拼写错误导致清单静默失效。
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning('应用清单 %s 顶层必须是 JSON 对象', path)
|
||||
return None
|
||||
if not _TryKeys(raw, _MANIFEST_KEYS, path, '顶层'):
|
||||
return None
|
||||
app_code = str(raw.get('AppCode') or '').strip()
|
||||
app_name = str(raw.get('AppName') or '').strip()
|
||||
if not app_code or not app_name:
|
||||
logger.warning('应用清单 %s 缺少 AppCode/AppName', path)
|
||||
return None
|
||||
|
||||
menus: List[AppMenuSpec] = []
|
||||
for item in raw.get('Menus') or []:
|
||||
if not isinstance(item, dict) or not _TryKeys(item, _MENU_KEYS, path, 'Menus'):
|
||||
return None
|
||||
menu_name = str(item.get('MenuName') or '').strip()
|
||||
if not menu_name:
|
||||
logger.warning('应用清单 %s 的 Menus 项缺少 MenuName', path)
|
||||
return None
|
||||
menus.append(AppMenuSpec(
|
||||
MenuName=menu_name,
|
||||
MenuTitle=str(item.get('MenuTitle') or ''),
|
||||
MenuLink=str(item.get('MenuLink') or ''),
|
||||
MenuIcon=str(item.get('MenuIcon') or ''),
|
||||
MenuSort=int(item.get('MenuSort') or 0),
|
||||
PermissionLevel=float(item.get('PermissionLevel') or 1.0),
|
||||
PermissionAttr=str(item.get('PermissionAttr') or ''),
|
||||
ParentMenuName=item.get('ParentMenuName') or None,
|
||||
))
|
||||
|
||||
config_keys: List[AppConfigSpec] = []
|
||||
for item in raw.get('ConfigKeys') or []:
|
||||
if not isinstance(item, dict) or not _TryKeys(item, _CONFIG_KEYS, path, 'ConfigKeys'):
|
||||
return None
|
||||
if not str(item.get('Key') or '').strip():
|
||||
logger.warning('应用清单 %s 的 ConfigKeys 项缺少 Key', path)
|
||||
return None
|
||||
config_keys.append(AppConfigSpec(
|
||||
Key=str(item['Key']),
|
||||
Label=str(item.get('Label') or ''),
|
||||
Default=str(item.get('Default') or ''),
|
||||
Secret=bool(item.get('Secret') or False),
|
||||
Placeholder=str(item.get('Placeholder') or ''),
|
||||
))
|
||||
|
||||
virtual_positions: List[VirtualPositionSpec] = []
|
||||
for item in raw.get('VirtualPositions') or []:
|
||||
if not isinstance(item, dict) or not _TryKeys(item, _VPOS_KEYS, path, 'VirtualPositions'):
|
||||
return None
|
||||
if not str(item.get('Code') or '').strip():
|
||||
logger.warning('应用清单 %s 的 VirtualPositions 项缺少 Code', path)
|
||||
return None
|
||||
virtual_positions.append(VirtualPositionSpec(
|
||||
Code=str(item['Code']),
|
||||
Name=str(item.get('Name') or ''),
|
||||
MenuNames=[str(x) for x in (item.get('MenuNames') or [])],
|
||||
))
|
||||
|
||||
position_templates: List[PositionTemplateSpec] = []
|
||||
for item in raw.get('PositionTemplates') or []:
|
||||
if not isinstance(item, dict) or not _TryKeys(item, _PTPL_KEYS, path, 'PositionTemplates'):
|
||||
return None
|
||||
if not str(item.get('CategoryName') or '').strip():
|
||||
logger.warning('应用清单 %s 的 PositionTemplates 项缺少 CategoryName', path)
|
||||
return None
|
||||
position_templates.append(PositionTemplateSpec(
|
||||
CategoryName=str(item['CategoryName']),
|
||||
Positions=[str(x) for x in (item.get('Positions') or [])],
|
||||
))
|
||||
|
||||
frontend_type = str(raw.get('FrontendType') or 'vue')
|
||||
if frontend_type not in ('vue', 'legacy'):
|
||||
logger.warning('应用清单 %s 的 FrontendType 非法:%s', path, frontend_type)
|
||||
return None
|
||||
app_type = str(raw.get('AppType') or 'enterprise')
|
||||
if app_type not in ('enterprise', 'personal', 'both'):
|
||||
logger.warning('应用清单 %s 的 AppType 非法:%s', path, app_type)
|
||||
return None
|
||||
|
||||
return AppManifest(
|
||||
AppCode=app_code,
|
||||
AppName=app_name,
|
||||
AppIcon=str(raw.get('AppIcon') or ''),
|
||||
AppDesc=str(raw.get('AppDesc') or ''),
|
||||
AppType=app_type,
|
||||
AuthRequired=int(raw.get('AuthRequired') or 0),
|
||||
RequirePositionBinding=int(raw.get('RequirePositionBinding') or 0),
|
||||
AppSort=int(raw.get('AppSort') or 0),
|
||||
DefaultPerms=str(raw.get('DefaultPerms') or ''),
|
||||
FrontendRoute=str(raw.get('FrontendRoute') or ''),
|
||||
FrontendType=frontend_type,
|
||||
LegacySrc=str(raw.get('LegacySrc') or ''),
|
||||
Menus=menus,
|
||||
ConfigKeys=config_keys,
|
||||
VirtualPositions=virtual_positions,
|
||||
PositionTemplates=position_templates,
|
||||
)
|
||||
|
||||
|
||||
def _LoadLocalManifests() -> List[AppManifest]:
|
||||
"""加载全部子应用清单文件(失败/非法的清单跳过并记日志,不影响其他应用)"""
|
||||
manifests: List[AppManifest] = []
|
||||
seen = set()
|
||||
for path in _ManifestFiles():
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
logger.exception('应用清单解析失败:%s', path)
|
||||
continue
|
||||
manifest = _ToManifest(raw, path)
|
||||
if manifest is None:
|
||||
continue
|
||||
if manifest.AppCode in seen:
|
||||
logger.warning('应用清单 AppCode 重复,已忽略:%s(%s)', manifest.AppCode, path)
|
||||
continue
|
||||
seen.add(manifest.AppCode)
|
||||
manifests.append(manifest)
|
||||
return manifests
|
||||
|
||||
|
||||
def _BuildAppManifests() -> List[AppManifest]:
|
||||
"""清单总表:全部应用清单均来自各子应用的 apps.gerp_manifests 文件"""
|
||||
return _LoadLocalManifests()
|
||||
|
||||
|
||||
APP_MANIFESTS: List[AppManifest] = _BuildAppManifests()
|
||||
|
||||
# ==================== MSYNC 远端清单注册中心(预留) ====================
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GerpConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gvsds.apps.gerp'
|
||||
label = 'gerp'
|
||||
verbose_name = 'GERP 企业管理'
|
||||
@@ -0,0 +1,63 @@
|
||||
"""GERP 子应用包(统一目录规约)
|
||||
|
||||
规约:一个 GERP 应用 = 本包下的一个子目录,目录名即应用英文 ID
|
||||
(如 `mailbox/`、`gitea/`、`oa/`、`talk/`),目录内固定结构:
|
||||
|
||||
apps/<appenglishid>/
|
||||
__init__.py
|
||||
models.py # 该应用的模型(app_label 仍为 'gerp',表名/迁移不变)
|
||||
manager.py # 可选:该应用的数据访问层(ORM 收敛处)
|
||||
views.py # 可选:该应用的视图
|
||||
urls.py # 可选:该应用的路由(在 gvsds/urls.py 内 include)
|
||||
permissions.py # 可选:该应用的 DRF 权限类
|
||||
apps.gerp_manifests # 应用清单声明(JSON,$schema 指向本包根下的
|
||||
# gerp_manifests.schema.json),由 app_manifests.py 扫描加载
|
||||
|
||||
重要(Django 约束):
|
||||
- 本包不能在 import 时导入各子应用的 models —— 那会在 app 注册阶段
|
||||
(模型未就绪)触发 AppRegistryNotReady。必须由 `gerp/models.py` 在模型加载
|
||||
阶段调用 `ImportSubAppModels()`。
|
||||
- 因本包与 Django 约定的 `<app>.apps` 同名,GERP 的 AppConfig 已移至
|
||||
`gvsds/apps/gerp/gerp_app.py`,并在 INSTALLED_APPS 用显式点分路径注册。
|
||||
- Django 一个 app 只能有一个迁移目录,故所有子应用的迁移仍写在
|
||||
`gvsds/apps/gerp/migrations/`,不分散到各子应用目录。
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ImportSubAppModels(namespace: dict = None) -> None:
|
||||
"""导入所有子应用的 models 模块(使 Django 感知模型)
|
||||
|
||||
Args:
|
||||
namespace: 传入调用方的 globals() 时,会把子应用模块中的公开名字
|
||||
(模型类等)注入该命名空间,用于保持
|
||||
`from gvsds.apps.gerp.models import X` 这类历史导入路径可用。
|
||||
已存在的名字不会被覆盖(平台模型优先)。
|
||||
"""
|
||||
for module_info in pkgutil.iter_modules(__path__):
|
||||
if not module_info.ispkg:
|
||||
continue
|
||||
app_id = module_info.name
|
||||
module_path = f'{__name__}.{app_id}.models'
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ModuleNotFoundError as exc:
|
||||
# 允许子应用没有 models.py(纯清单/纯 legacy 应用)
|
||||
if exc.name and exc.name.startswith(module_path):
|
||||
logger.debug('子应用 %s 无 models 模块,跳过', app_id)
|
||||
continue
|
||||
logger.exception('导入子应用 %s 的 models 失败', app_id)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('导入子应用 %s 的 models 失败', app_id)
|
||||
raise
|
||||
if namespace is not None:
|
||||
for name, value in vars(module).items():
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
namespace.setdefault(name, value)
|
||||
@@ -0,0 +1 @@
|
||||
"""鼎算财务(GERP 子应用)"""
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "dingcalc-finance",
|
||||
"AppName": "鼎算财务",
|
||||
"AppIcon": "Money",
|
||||
"AppDesc": "鼎算财务:企业财务管理(凭证/出纳/会计/报表/报销/应收应付/预算)",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 7,
|
||||
"RequirePositionBinding": 1,
|
||||
"FrontendRoute": "/finance/voucher",
|
||||
"FrontendType": "vue",
|
||||
"LegacySrc": "",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "finance-voucher",
|
||||
"MenuTitle": "凭证管理",
|
||||
"MenuLink": "/finance/voucher",
|
||||
"MenuIcon": "Tickets",
|
||||
"MenuSort": 1,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-cashier",
|
||||
"MenuTitle": "出纳管理",
|
||||
"MenuLink": "/finance/cashier",
|
||||
"MenuIcon": "Coin",
|
||||
"MenuSort": 2,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-ledger",
|
||||
"MenuTitle": "会计账簿",
|
||||
"MenuLink": "/finance/ledger",
|
||||
"MenuIcon": "Notebook",
|
||||
"MenuSort": 3,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-report",
|
||||
"MenuTitle": "财务报表",
|
||||
"MenuLink": "/finance/report",
|
||||
"MenuIcon": "DataAnalysis",
|
||||
"MenuSort": 4,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-reimburse",
|
||||
"MenuTitle": "费用报销",
|
||||
"MenuLink": "/finance/reimburse",
|
||||
"MenuIcon": "Money",
|
||||
"MenuSort": 5,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-arap",
|
||||
"MenuTitle": "应收应付",
|
||||
"MenuLink": "/finance/arap",
|
||||
"MenuIcon": "Refresh",
|
||||
"MenuSort": 6,
|
||||
"ParentMenuName": "finance"
|
||||
},
|
||||
{
|
||||
"MenuName": "finance-budget",
|
||||
"MenuTitle": "预算管理",
|
||||
"MenuLink": "/finance/budget",
|
||||
"MenuIcon": "PieChart",
|
||||
"MenuSort": 7,
|
||||
"ParentMenuName": "finance"
|
||||
}
|
||||
],
|
||||
"VirtualPositions": [
|
||||
{
|
||||
"Code": "finance-clerk",
|
||||
"Name": "财务专员",
|
||||
"MenuNames": ["finance-voucher", "finance-reimburse", "finance-arap"]
|
||||
},
|
||||
{
|
||||
"Code": "cashier",
|
||||
"Name": "出纳",
|
||||
"MenuNames": ["finance-cashier", "finance-reimburse"]
|
||||
},
|
||||
{
|
||||
"Code": "accountant",
|
||||
"Name": "会计",
|
||||
"MenuNames": ["finance-voucher", "finance-ledger", "finance-arap"]
|
||||
},
|
||||
{
|
||||
"Code": "finance-supervisor",
|
||||
"Name": "财务主管",
|
||||
"MenuNames": ["finance-voucher", "finance-report", "finance-budget"]
|
||||
},
|
||||
{
|
||||
"Code": "finance-manager",
|
||||
"Name": "财务经理",
|
||||
"MenuNames": [
|
||||
"finance-voucher", "finance-cashier", "finance-ledger",
|
||||
"finance-report", "finance-reimburse", "finance-arap", "finance-budget"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Code": "finance-director",
|
||||
"Name": "财务总监",
|
||||
"MenuNames": [
|
||||
"finance-voucher", "finance-cashier", "finance-ledger",
|
||||
"finance-report", "finance-reimburse", "finance-arap", "finance-budget"
|
||||
]
|
||||
}
|
||||
],
|
||||
"PositionTemplates": [
|
||||
{
|
||||
"CategoryName": "财务",
|
||||
"Positions": ["财务专员", "出纳", "会计", "财务主管", "财务经理", "财务总监"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"""鼎算财务 模型(app_label 保持 'gerp',表名与列名不变)"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class FinanceVoucher(QModel):
|
||||
"""凭证主表——鼎算财务:记账凭证头(含借贷合计冗余)"""
|
||||
|
||||
VoucherUUID = UUIDField(primary_key=True, verbose_name='凭证UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
VoucherCode = models.CharField(max_length=32, default='', verbose_name='凭证字号')
|
||||
VoucherDate = models.DateField(null=True, blank=True, verbose_name='凭证日期')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
Summary = models.CharField(max_length=256, default='', verbose_name='摘要')
|
||||
TotalDebit = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方合计')
|
||||
TotalCredit = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方合计')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=草稿 1=已过账 2=已作废
|
||||
CreatorUUID = UUIDField(null=True, blank=True, verbose_name='制单人UUID')
|
||||
AuditorUUID = UUIDField(null=True, blank=True, verbose_name='审核人UUID')
|
||||
AuditTime = models.DateTimeField(null=True, blank=True, verbose_name='审核时间')
|
||||
AttachmentCount = models.IntegerField(default=0, verbose_name='附件数')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceVoucher'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-VoucherDate', '-CreateTime']
|
||||
verbose_name = '凭证'
|
||||
verbose_name_plural = '凭证'
|
||||
|
||||
def __str__(self):
|
||||
return self.VoucherCode or str(self.pk)
|
||||
|
||||
|
||||
class FinanceVoucherEntry(QModel):
|
||||
"""凭证分录表——鼎算财务:一张凭证的多行借贷分录"""
|
||||
|
||||
VoucherEntryUUID = UUIDField(primary_key=True, verbose_name='分录UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
VoucherUUID = UUIDField(db_column='VoucherUUID', verbose_name='所属凭证UUID')
|
||||
AccountCode = models.CharField(max_length=32, default='', verbose_name='科目编码')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='科目名称')
|
||||
EntrySummary = models.CharField(max_length=256, default='', verbose_name='分录摘要')
|
||||
DebitAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方金额')
|
||||
CreditAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方金额')
|
||||
SortOrder = models.IntegerField(default=0, verbose_name='排序')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceVoucherEntry'
|
||||
app_label = 'gerp'
|
||||
ordering = ['SortOrder', 'CreateTime']
|
||||
verbose_name = '凭证分录'
|
||||
verbose_name_plural = '凭证分录'
|
||||
|
||||
|
||||
class FinanceCashier(QModel):
|
||||
"""出纳流水表——鼎算财务:现金/银行存款收支记录"""
|
||||
|
||||
CashierUUID = UUIDField(primary_key=True, verbose_name='流水UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
TradeDate = models.DateField(null=True, blank=True, verbose_name='日期')
|
||||
TradeType = models.SmallIntegerField(default=1, verbose_name='类型') # 1=收入 2=支出 3=转账
|
||||
BankAccount = models.CharField(max_length=64, default='', verbose_name='银行账户')
|
||||
Counterparty = models.CharField(max_length=128, default='', verbose_name='对方单位/个人')
|
||||
Amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='结余')
|
||||
OperatorUUID = UUIDField(null=True, blank=True, verbose_name='经办人UUID')
|
||||
RelatedVoucherUUID = UUIDField(null=True, blank=True, db_column='RelatedVoucherUUID', verbose_name='关联凭证UUID')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceCashier'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-TradeDate', '-CreateTime']
|
||||
verbose_name = '出纳流水'
|
||||
verbose_name_plural = '出纳流水'
|
||||
|
||||
|
||||
class FinanceLedger(QModel):
|
||||
"""会计账簿表——鼎算财务:科目发生额与余额记录"""
|
||||
|
||||
LedgerUUID = UUIDField(primary_key=True, verbose_name='账簿UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
EntryDate = models.DateField(null=True, blank=True, verbose_name='记账日期')
|
||||
AccountCode = models.CharField(max_length=32, default='', verbose_name='科目编码')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='科目名称')
|
||||
Summary = models.CharField(max_length=256, default='', verbose_name='摘要')
|
||||
DebitAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方金额')
|
||||
CreditAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='余额')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceLedger'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-EntryDate', '-CreateTime']
|
||||
verbose_name = '会计账簿'
|
||||
verbose_name_plural = '会计账簿'
|
||||
|
||||
|
||||
class FinanceReport(QModel):
|
||||
"""财务报表快照表——鼎算财务:利润表/资产负债表项目行"""
|
||||
|
||||
ReportUUID = UUIDField(primary_key=True, verbose_name='报表UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReportType = models.SmallIntegerField(default=1, verbose_name='报表类型') # 1=利润表 2=资产负债表
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
ItemCode = models.CharField(max_length=32, default='', verbose_name='项目编码')
|
||||
ItemName = models.CharField(max_length=64, default='', verbose_name='项目名称')
|
||||
CurrentAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='本期金额')
|
||||
CumulativeAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='累计金额')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceReport'
|
||||
app_label = 'gerp'
|
||||
ordering = ['Period', 'ReportType', 'ItemCode']
|
||||
verbose_name = '财务报表'
|
||||
verbose_name_plural = '财务报表'
|
||||
|
||||
|
||||
class FinanceReimburse(QModel):
|
||||
"""费用报销表——鼎算财务:报销单与审批流"""
|
||||
|
||||
ReimburseUUID = UUIDField(primary_key=True, verbose_name='报销单UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReimburseCode = models.CharField(max_length=32, default='', verbose_name='报销单号')
|
||||
ApplicantUUID = UUIDField(null=True, blank=True, verbose_name='报销人UUID')
|
||||
DepartmentName = models.CharField(max_length=64, default='', verbose_name='部门')
|
||||
ExpenseType = models.CharField(max_length=32, default='', verbose_name='费用类型')
|
||||
Amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='金额')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='事由')
|
||||
InvoiceCount = models.IntegerField(default=0, verbose_name='发票数')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=待审批 1=已通过 2=已驳回 3=已打款
|
||||
AuditorUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
AuditTime = models.DateTimeField(null=True, blank=True, verbose_name='审批时间')
|
||||
AuditRemark = models.CharField(max_length=256, default='', verbose_name='审批意见')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceReimburse'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '费用报销'
|
||||
verbose_name_plural = '费用报销'
|
||||
|
||||
|
||||
class FinanceArap(QModel):
|
||||
"""应收应付表——鼎算财务:往来账款与结算"""
|
||||
|
||||
ArapUUID = UUIDField(primary_key=True, verbose_name='往来UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ArapType = models.SmallIntegerField(default=1, verbose_name='类型') # 1=应收 2=应付
|
||||
CounterpartyUUID = UUIDField(null=True, blank=True, db_column='CounterpartyUUID', verbose_name='往来对象UUID')
|
||||
CounterpartyName = models.CharField(max_length=128, default='', verbose_name='往来对象名称')
|
||||
TotalAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='总额')
|
||||
PaidAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='已收付金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='余额')
|
||||
DueDate = models.DateField(null=True, blank=True, verbose_name='到期日')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=未结清 1=部分结清 2=已结清
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceArap'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '应收应付'
|
||||
verbose_name_plural = '应收应付'
|
||||
|
||||
|
||||
class FinanceBudget(QModel):
|
||||
"""预算管理表——鼎算财务:期间×部门/科目的预算与执行"""
|
||||
|
||||
BudgetUUID = UUIDField(primary_key=True, verbose_name='预算UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
DeptUUID = UUIDField(null=True, blank=True, db_column='DeptUUID', verbose_name='部门UUID')
|
||||
DeptName = models.CharField(max_length=64, default='', verbose_name='部门名称')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='预算科目')
|
||||
BudgetAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='预算金额')
|
||||
ExecutedAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='已执行金额')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceBudget'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '预算管理'
|
||||
verbose_name_plural = '预算管理'
|
||||
@@ -0,0 +1 @@
|
||||
"""eHRM 人力资源扩展(GERP 子应用)——按 GERP 子应用目录规约组织,迁移仍集中在 gerp/migrations/。"""
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "ehrm",
|
||||
"AppName": "人力资源管理扩展",
|
||||
"AppIcon": "UserFilled",
|
||||
"AppDesc": "eHRM:员工档案、考核与绩效管理(与用户详情页联动)",
|
||||
"AppType": "enterprise",
|
||||
"RequirePositionBinding": 1,
|
||||
"AppSort": 9,
|
||||
"FrontendRoute": "/ehrm/profile",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "ehrm-profile",
|
||||
"MenuTitle": "档案管理",
|
||||
"MenuLink": "/ehrm/profile",
|
||||
"MenuIcon": "Document",
|
||||
"MenuSort": 1,
|
||||
"ParentMenuName": "ehrm"
|
||||
},
|
||||
{
|
||||
"MenuName": "ehrm-assessment",
|
||||
"MenuTitle": "考核管理",
|
||||
"MenuLink": "/ehrm/assessment",
|
||||
"MenuIcon": "Aim",
|
||||
"MenuSort": 2,
|
||||
"ParentMenuName": "ehrm"
|
||||
},
|
||||
{
|
||||
"MenuName": "ehrm-performance",
|
||||
"MenuTitle": "绩效管理",
|
||||
"MenuLink": "/ehrm/performance",
|
||||
"MenuIcon": "TrendCharts",
|
||||
"MenuSort": 3,
|
||||
"ParentMenuName": "ehrm"
|
||||
}
|
||||
],
|
||||
"VirtualPositions": [
|
||||
{
|
||||
"Code": "hr-assistant",
|
||||
"Name": "人事专员",
|
||||
"MenuNames": ["ehrm-profile"]
|
||||
},
|
||||
{
|
||||
"Code": "assessment-clerk",
|
||||
"Name": "考核专员",
|
||||
"MenuNames": ["ehrm-assessment"]
|
||||
},
|
||||
{
|
||||
"Code": "performance-clerk",
|
||||
"Name": "绩效专员",
|
||||
"MenuNames": ["ehrm-performance"]
|
||||
},
|
||||
{
|
||||
"Code": "hr-supervisor",
|
||||
"Name": "人事主管",
|
||||
"MenuNames": ["ehrm-profile", "ehrm-assessment", "ehrm-performance"]
|
||||
},
|
||||
{
|
||||
"Code": "hr-manager",
|
||||
"Name": "人力资源经理",
|
||||
"MenuNames": ["ehrm-profile", "ehrm-assessment", "ehrm-performance"]
|
||||
}
|
||||
],
|
||||
"PositionTemplates": [
|
||||
{
|
||||
"CategoryName": "人事",
|
||||
"Positions": ["人事专员", "考核专员", "绩效专员", "人事主管", "人力资源经理"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""eHRM 模型(app_label 保持 'gerp',表名与列名不变)"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class EmployeeProfile(QModel):
|
||||
"""员工档案表——eHRM:员工基本信息与任职档案"""
|
||||
|
||||
EmployeeProfileUUID = UUIDField(primary_key=True, verbose_name='档案UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
DeptUUID = UUIDField(null=True, blank=True, db_column='DeptUUID', verbose_name='部门UUID')
|
||||
DeptName = models.CharField(max_length=64, default='', verbose_name='部门名称')
|
||||
EmployeeNo = models.CharField(max_length=32, default='', verbose_name='工号')
|
||||
HireDate = models.DateField(null=True, blank=True, verbose_name='入职日期')
|
||||
PositionName = models.CharField(max_length=64, default='', verbose_name='职务')
|
||||
Education = models.CharField(max_length=32, default='', verbose_name='学历')
|
||||
EmergencyContact = models.CharField(max_length=32, default='', verbose_name='紧急联系人')
|
||||
EmergencyPhone = models.CharField(max_length=32, default='', verbose_name='紧急联系电话')
|
||||
Address = models.CharField(max_length=256, default='', verbose_name='联系地址')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeeProfile'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工档案'
|
||||
verbose_name_plural = '员工档案'
|
||||
|
||||
|
||||
class EmployeeAssessment(QModel):
|
||||
"""员工考核表——eHRM:考核期间、评分与等级结论"""
|
||||
|
||||
AssessmentUUID = UUIDField(primary_key=True, verbose_name='考核UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='考核期间')
|
||||
AssessorUUID = UUIDField(null=True, blank=True, verbose_name='考核人UUID')
|
||||
Score = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='评分')
|
||||
Grade = models.CharField(max_length=16, default='', verbose_name='等级')
|
||||
Result = models.CharField(max_length=512, default='', verbose_name='考核结论')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=草稿 1=已确认
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeeAssessment'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工考核'
|
||||
verbose_name_plural = '员工考核'
|
||||
|
||||
|
||||
class EmployeePerformance(QModel):
|
||||
"""员工绩效表——eHRM:绩效指标的目标/实际值与得分"""
|
||||
|
||||
PerformanceUUID = UUIDField(primary_key=True, verbose_name='绩效UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='绩效期间')
|
||||
MetricName = models.CharField(max_length=64, default='', verbose_name='绩效指标')
|
||||
TargetValue = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='目标值')
|
||||
ActualValue = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='实际值')
|
||||
Score = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='得分')
|
||||
Weight = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='权重(%)')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeePerformance'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工绩效'
|
||||
verbose_name_plural = '员工绩效'
|
||||
@@ -0,0 +1 @@
|
||||
"""枢机协律-模拟企业(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)"""
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "enterprise",
|
||||
"AppName": "枢机协律-模拟企业",
|
||||
"AppIcon": "OfficeBuilding",
|
||||
"AppDesc": "枢机协律-模拟企业:企业信息管理(工商注册与企业详情)",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 6,
|
||||
"FrontendRoute": "/private/CreateE/enterprise",
|
||||
"FrontendType": "legacy",
|
||||
"LegacySrc": "pages/private/CreateE/enterprise.html",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "create-e-enterprise",
|
||||
"MenuTitle": "企业管理",
|
||||
"MenuLink": "pages/private/CreateE/enterprise.html",
|
||||
"MenuIcon": "OfficeBuilding",
|
||||
"MenuSort": 1,
|
||||
"ParentMenuName": "create-e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://gvsds.com/schemas/gerp_manifests.schema.json",
|
||||
"title": "GERP 应用清单(apps.gerp_manifests)",
|
||||
"description": "GERP 子应用的清单声明格式。每个 gvsds/apps/gerp/apps/<appenglishid>/apps.gerp_manifests 文件声明一个应用(AppManifest),由 gvsds/apps/gerp/app_manifests.py 扫描加载并同步到 App/Menu 表。字段与 AppManifest/AppMenuSpec/AppConfigSpec/VirtualPositionSpec/PositionTemplateSpec 数据类一一对应。",
|
||||
"type": "object",
|
||||
"required": ["AppCode", "AppName"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "指向总 schema(相对路径 ../gerp_manifests.schema.json),仅供编辑器校验用"
|
||||
},
|
||||
"AppCode": {
|
||||
"type": "string",
|
||||
"description": "应用编码(全局唯一,与 App.AppCode 一致)",
|
||||
"minLength": 1
|
||||
},
|
||||
"AppName": {
|
||||
"type": "string",
|
||||
"description": "应用名称(应用市场展示)",
|
||||
"minLength": 1
|
||||
},
|
||||
"AppIcon": {
|
||||
"type": "string",
|
||||
"description": "图标(Element Plus 图标名或 FontAwesome 类名)"
|
||||
},
|
||||
"AppDesc": {
|
||||
"type": "string",
|
||||
"description": "应用简介"
|
||||
},
|
||||
"AppType": {
|
||||
"type": "string",
|
||||
"enum": ["enterprise", "personal", "both"],
|
||||
"default": "enterprise",
|
||||
"description": "enterprise=企业功能(个人不可自助启用)/ personal=个人应用 / both=两者皆可"
|
||||
},
|
||||
"AuthRequired": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 0,
|
||||
"description": "是否需要授权(0/1)"
|
||||
},
|
||||
"RequirePositionBinding": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 0,
|
||||
"description": "是否要求绑定职务后才可见(0/1)"
|
||||
},
|
||||
"AppSort": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "应用市场排序(小在前)"
|
||||
},
|
||||
"DefaultPerms": {
|
||||
"type": "string",
|
||||
"description": "默认权限码,多个用 ; 分隔"
|
||||
},
|
||||
"FrontendRoute": {
|
||||
"type": "string",
|
||||
"description": "前端路由(安装后“打开应用”跳转目标),如 /talk"
|
||||
},
|
||||
"FrontendType": {
|
||||
"type": "string",
|
||||
"enum": ["vue", "legacy"],
|
||||
"default": "vue",
|
||||
"description": "vue=前端组件路由 / legacy=旧页面 iframe"
|
||||
},
|
||||
"LegacySrc": {
|
||||
"type": "string",
|
||||
"description": "legacy 页面路径(如 pages/private/CreateE/shop.html)"
|
||||
},
|
||||
"Menus": {
|
||||
"type": "array",
|
||||
"description": "安装后注入 GERP 侧边栏的菜单节点",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["MenuName", "MenuTitle"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"MenuName": {
|
||||
"type": "string",
|
||||
"description": "菜单全局唯一标识(upsert 定位键)",
|
||||
"minLength": 1
|
||||
},
|
||||
"MenuTitle": {
|
||||
"type": "string",
|
||||
"description": "菜单显示名"
|
||||
},
|
||||
"MenuLink": {
|
||||
"type": "string",
|
||||
"description": "菜单链接(前端会规范化为路由 path,如 pages/talk/index.html → /talk)"
|
||||
},
|
||||
"MenuIcon": {
|
||||
"type": "string"
|
||||
},
|
||||
"MenuSort": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"PermissionLevel": {
|
||||
"type": "number",
|
||||
"default": 1.0
|
||||
},
|
||||
"PermissionAttr": {
|
||||
"type": "string",
|
||||
"description": "权限属性(如 admin:super / admin:tenant)"
|
||||
},
|
||||
"ParentMenuName": {
|
||||
"type": ["string", "null"],
|
||||
"description": "父菜单 MenuName(null 或省略=一级菜单);如 company=公司、myself=我的"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfigKeys": {
|
||||
"type": "array",
|
||||
"description": "应用配置键模板(应用配置页表单用)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["Key"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Key": { "type": "string", "minLength": 1 },
|
||||
"Label": { "type": "string" },
|
||||
"Default": { "type": "string" },
|
||||
"Secret": { "type": "boolean", "default": false },
|
||||
"Placeholder": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"VirtualPositions": {
|
||||
"type": "array",
|
||||
"description": "应用自定义的虚拟职务(绑定实际职务后决定菜单可见性)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["Code"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Code": { "type": "string", "minLength": 1 },
|
||||
"Name": { "type": "string" },
|
||||
"MenuNames": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PositionTemplates": {
|
||||
"type": "array",
|
||||
"description": "应用需要的通用部门与职务模板(按名称同步)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["CategoryName"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"CategoryName": { "type": "string", "minLength": 1 },
|
||||
"Positions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Gitea 代码托管(GERP 子应用)——Gitea 账号申请、组织团队加入、改密、头像迁移与管理员禁用。"""
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "gitea",
|
||||
"AppName": "代码托管 Gitea",
|
||||
"AppIcon": "Connection",
|
||||
"AppDesc": "企业代码托管平台,支持免密登录与组织协作",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 2,
|
||||
"FrontendRoute": "/gitea/mine",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "gitea-mine",
|
||||
"MenuTitle": "我的 Gitea",
|
||||
"MenuLink": "pages/gitea/mine.html",
|
||||
"MenuIcon": "Connection",
|
||||
"MenuSort": 4,
|
||||
"ParentMenuName": "myself"
|
||||
},
|
||||
{
|
||||
"MenuName": "gitea-admin",
|
||||
"MenuTitle": "Gitea 账号管理",
|
||||
"MenuLink": "pages/gitea/admin.html",
|
||||
"MenuIcon": "Connection",
|
||||
"MenuSort": 7,
|
||||
"ParentMenuName": "company"
|
||||
}
|
||||
],
|
||||
"ConfigKeys": [
|
||||
{ "Key": "ORG_NAME", "Label": "组织名", "Placeholder": "如 GVSDS" },
|
||||
{ "Key": "DOMAIN", "Label": "托管域名", "Placeholder": "如 git.gvsds.com" },
|
||||
{ "Key": "ALLOW_COMPANY", "Label": "可用公司(留空=全部公司)", "Placeholder": "公司UUID,多个用逗号分隔" }
|
||||
]
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,34 @@
|
||||
"""代码托管 Gitea 模型(app_label 保持 'gerp',表名与列名不变)"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class UserGiteaAccount(QModel):
|
||||
"""用户 Gitea 账号表——记录员工与 Gitea 账号的映射
|
||||
|
||||
申请 Gitea 账号时在 git.gvsds.com 创建用户并加入组织(GVSDS),
|
||||
本表记录归属关系;密码不落库(员工自设,仅存于 Gitea)。
|
||||
"""
|
||||
|
||||
UserGiteaAccountUUID = UUIDField(primary_key=True, verbose_name='Gitea记录UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='用户UUID')
|
||||
GiteaLogin = models.CharField(max_length=256, unique=True, verbose_name='Gitea用户名')
|
||||
GiteaUserId = models.IntegerField(null=True, blank=True, verbose_name='Gitea用户ID')
|
||||
Email = models.CharField(max_length=256, default='', blank=True, db_column='Email', verbose_name='工作邮箱')
|
||||
Status = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'UserGiteaAccount'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '用户Gitea账号'
|
||||
verbose_name_plural = '用户Gitea账号'
|
||||
|
||||
def __str__(self):
|
||||
return self.GiteaLogin
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Gitea 代码托管(gitea)路由
|
||||
|
||||
挂载点:`gvsds/urls.py` → `path('API/Gitea/', include('gvsds.apps.gerp.apps.gitea.urls'))`
|
||||
路径与 name 与拆分前保持逐字一致(前端按 name/字面路径调用,不得变更)。
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('Session', views.GiteaSessionView.as_view(), name='gitea_session'),
|
||||
path('Apply', views.GiteaApplyView.as_view(), name='gitea_apply'),
|
||||
path('MyAccount', views.GiteaMyAccountView.as_view(), name='gitea_my_account'),
|
||||
path('ChangePassword', views.GiteaChangePasswordView.as_view(), name='gitea_change_password'),
|
||||
path('Avatar', views.GiteaAvatarView.as_view(), name='gitea_avatar'),
|
||||
path('AdminList', views.GiteaAdminListView.as_view(), name='gitea_admin_list'),
|
||||
path('Disable', views.GiteaDisableView.as_view(), name='gitea_disable'),
|
||||
]
|
||||
@@ -21,15 +21,15 @@ from rest_framework.response import Response
|
||||
from gvsds import secrets as app_secrets
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
from gvsdsdk.auth.permissions import IsAdminUser
|
||||
from ..app_access import AppAccessPermission
|
||||
from gvsds.apps.gerp.app_access import AppAccessPermission
|
||||
|
||||
# Gitea 功能应用编码(未安装的企业租户员工无法使用代码托管)
|
||||
_GITEA_ACCESS = AppAccessPermission('gitea')
|
||||
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from ..gitea_manager import GiteaManager
|
||||
from ..mail_manager import MailManager
|
||||
from ..gitea import GiteaApiClient, GiteaError
|
||||
from gvsds.apps.gerp.views import ELTAuthenticatedAPIView
|
||||
from gvsds.apps.gerp.apps.mailbox.manager import MailManager
|
||||
from .manager import GiteaManager
|
||||
from .gitea import GiteaApiClient, GiteaError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox 企业邮箱(GERP 子应用)——Stalwart 邮箱申请、修改密码、JMAP 收发代理与联系人管理。"""
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "mailbox",
|
||||
"AppName": "企业邮箱",
|
||||
"AppIcon": "Message",
|
||||
"AppDesc": "企业级邮件服务(Stalwart),支持多邮箱申请与安全浏览",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 1,
|
||||
"FrontendRoute": "/mail/mailbox",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "mailbox",
|
||||
"MenuTitle": "我的邮箱",
|
||||
"MenuLink": "pages/mail/mailbox.html",
|
||||
"MenuIcon": "Message",
|
||||
"MenuSort": 3,
|
||||
"ParentMenuName": "myself"
|
||||
},
|
||||
{
|
||||
"MenuName": "mailbox-manage",
|
||||
"MenuTitle": "邮箱管理",
|
||||
"MenuLink": "pages/mail/admin.html",
|
||||
"MenuIcon": "Message",
|
||||
"MenuSort": 6,
|
||||
"ParentMenuName": "company"
|
||||
}
|
||||
],
|
||||
"ConfigKeys": [
|
||||
{ "Key": "SMTP_HOST", "Label": "SMTP 服务器", "Placeholder": "如 smtp.gvsds.com" },
|
||||
{ "Key": "SMTP_PORT", "Label": "SMTP 端口", "Default": "465" },
|
||||
{ "Key": "DOMAIN", "Label": "邮箱域名", "Placeholder": "如 gvsds.com" },
|
||||
{ "Key": "ALLOW_COMPANY", "Label": "可用公司(留空=全部公司)", "Placeholder": "公司UUID,多个用逗号分隔" }
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,63 @@
|
||||
"""企业邮箱 模型(app_label 保持 'gerp',表名与列名不变)"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.apps.gerp.models import _NewUuidBytes
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class UserMailbox(QModel):
|
||||
"""用户邮箱表——记录员工与 Stalwart 邮箱账户的映射
|
||||
|
||||
申请邮箱时在 Stalwart 创建 individual principal(xxx@gvsds.com),
|
||||
本表记录归属关系与配额;密码不落库(员工自设,仅存于 Stalwart)。
|
||||
"""
|
||||
|
||||
UserMailboxUUID = UUIDField(primary_key=True, verbose_name='邮箱记录UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='用户UUID')
|
||||
MailboxName = models.CharField(max_length=256, unique=True, verbose_name='邮箱名称')
|
||||
PrincipalId = models.IntegerField(null=True, blank=True, verbose_name='Stalwart账户ID')
|
||||
QuotaMB = models.IntegerField(default=1024, verbose_name='配额(MB)')
|
||||
Status = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'UserMailbox'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '用户邮箱'
|
||||
verbose_name_plural = '用户邮箱'
|
||||
|
||||
def __str__(self):
|
||||
return self.MailboxName
|
||||
|
||||
|
||||
class MailContact(QModel):
|
||||
"""邮箱联系人表——发件时自动存储常用联系人(云端 SQL 存储)
|
||||
|
||||
发信成功后自动把收件人存入本表(按 用户+邮箱 去重),
|
||||
写信时前端按关键字匹配下拉选择;联系人名取自联系人显示名(可后续编辑)。
|
||||
"""
|
||||
|
||||
MailContactUUID = UUIDField(primary_key=True, default=_NewUuidBytes, verbose_name='联系人UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='归属用户UUID')
|
||||
ContactName = models.CharField(max_length=128, default='', blank=True, verbose_name='联系人名称')
|
||||
ContactEmail = models.CharField(max_length=256, db_index=True, verbose_name='联系人邮箱')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'MailContact'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-UpdateTime']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['UserUUID', 'ContactEmail'], name='uniq_user_contact_email'),
|
||||
]
|
||||
verbose_name = '邮箱联系人'
|
||||
verbose_name_plural = '邮箱联系人'
|
||||
|
||||
def __str__(self):
|
||||
return self.ContactEmail
|
||||
@@ -0,0 +1,22 @@
|
||||
"""企业邮箱(mailbox)路由
|
||||
|
||||
挂载点:`gvsds/urls.py` → `path('API/Mail/', include('gvsds.apps.gerp.apps.mailbox.urls'))`
|
||||
路径与 name 与拆分前保持逐字一致(前端按 name/字面路径调用,不得变更)。
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('Session', views.MailSessionView.as_view(), name='mail_session'),
|
||||
path('Apply', views.MailApplyView.as_view(), name='mail_apply'),
|
||||
path('ChangePassword', views.MailChangePasswordView.as_view(), name='mail_change_password'),
|
||||
path('MyMailbox', views.MailMyMailboxView.as_view(), name='mail_my_mailbox'),
|
||||
path('Login', views.MailLoginView.as_view(), name='mail_login'),
|
||||
path('Logout', views.MailLogoutView.as_view(), name='mail_logout'),
|
||||
path('Jmap', views.MailJmapView.as_view(), name='mail_jmap'),
|
||||
path('Contacts', views.MailContactsView.as_view(), name='mail_contacts'),
|
||||
path('AdminList', views.MailAdminListView.as_view(), name='mail_admin_list'),
|
||||
path('Disable', views.MailDisableView.as_view(), name='mail_disable'),
|
||||
]
|
||||
@@ -26,14 +26,14 @@ from rest_framework.response import Response
|
||||
from gvsds import secrets as app_secrets
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
from gvsdsdk.auth.permissions import IsAdminUser
|
||||
from ..app_access import AppAccessPermission
|
||||
from gvsds.apps.gerp.app_access import AppAccessPermission
|
||||
|
||||
# 邮箱功能应用编码(未安装的企业租户员工无法使用邮箱)
|
||||
_MAIL_ACCESS = AppAccessPermission('mailbox')
|
||||
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from ..mail_manager import MailManager
|
||||
from ..stalwart import StalwartMailManager, StalwartMailError
|
||||
from gvsds.apps.gerp.views import ELTAuthenticatedAPIView
|
||||
from gvsds.apps.gerp.stalwart import StalwartMailManager, StalwartMailError
|
||||
from .manager import MailManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""OA 办公自动化(GERP 子应用)——按 GERP 子应用目录规约组织,迁移仍集中在 gerp/migrations/。"""
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "oa",
|
||||
"AppName": "办公自动化流程",
|
||||
"AppIcon": "Files",
|
||||
"AppDesc": "OA 办公自动化:请假/出差申请、会议管理、公告通知与工作汇报",
|
||||
"AppType": "enterprise",
|
||||
"RequirePositionBinding": 1,
|
||||
"AppSort": 10,
|
||||
"FrontendRoute": "/oa/leave",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "oa-approval",
|
||||
"MenuTitle": "审批",
|
||||
"MenuLink": "/oa/approval",
|
||||
"MenuIcon": "Stamp",
|
||||
"MenuSort": 0,
|
||||
"ParentMenuName": "oa"
|
||||
},
|
||||
{
|
||||
"MenuName": "oa-leave",
|
||||
"MenuTitle": "请假申请",
|
||||
"MenuLink": "/oa/leave",
|
||||
"MenuIcon": "Calendar",
|
||||
"MenuSort": 1,
|
||||
"ParentMenuName": "oa"
|
||||
},
|
||||
{
|
||||
"MenuName": "oa-travel",
|
||||
"MenuTitle": "出差申请",
|
||||
"MenuLink": "/oa/travel",
|
||||
"MenuIcon": "Promotion",
|
||||
"MenuSort": 2,
|
||||
"ParentMenuName": "oa"
|
||||
},
|
||||
{
|
||||
"MenuName": "oa-meeting",
|
||||
"MenuTitle": "会议管理",
|
||||
"MenuLink": "/oa/meeting",
|
||||
"MenuIcon": "OfficeBuilding",
|
||||
"MenuSort": 3,
|
||||
"ParentMenuName": "oa"
|
||||
},
|
||||
{
|
||||
"MenuName": "oa-announcement",
|
||||
"MenuTitle": "公告通知",
|
||||
"MenuLink": "/oa/announcement",
|
||||
"MenuIcon": "Bell",
|
||||
"MenuSort": 4,
|
||||
"ParentMenuName": "oa"
|
||||
},
|
||||
{
|
||||
"MenuName": "oa-report",
|
||||
"MenuTitle": "工作汇报",
|
||||
"MenuLink": "/oa/report",
|
||||
"MenuIcon": "Document",
|
||||
"MenuSort": 5,
|
||||
"ParentMenuName": "oa"
|
||||
}
|
||||
],
|
||||
"VirtualPositions": [
|
||||
{
|
||||
"Code": "oa-staff",
|
||||
"Name": "普通员工",
|
||||
"MenuNames": ["oa-leave"]
|
||||
},
|
||||
{
|
||||
"Code": "oa-clerk",
|
||||
"Name": "行政专员",
|
||||
"MenuNames": ["oa-approval", "oa-leave", "oa-travel", "oa-meeting", "oa-report"]
|
||||
},
|
||||
{
|
||||
"Code": "oa-supervisor",
|
||||
"Name": "行政主管",
|
||||
"MenuNames": ["oa-approval", "oa-leave", "oa-travel", "oa-meeting", "oa-announcement", "oa-report"]
|
||||
},
|
||||
{
|
||||
"Code": "oa-manager",
|
||||
"Name": "行政经理",
|
||||
"MenuNames": ["oa-approval", "oa-leave", "oa-travel", "oa-meeting", "oa-announcement", "oa-report"]
|
||||
}
|
||||
],
|
||||
"PositionTemplates": [
|
||||
{
|
||||
"CategoryName": "行政",
|
||||
"Positions": ["普通员工", "行政专员", "行政主管", "行政经理"]
|
||||
}
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,121 @@
|
||||
"""OA 模型(app_label 保持 'gerp',表名与列名不变)"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class OaLeave(QModel):
|
||||
"""请假申请表——OA:请假类型、起止时间与审批状态"""
|
||||
|
||||
OaLeaveUUID = UUIDField(primary_key=True, verbose_name='请假UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='申请人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
LeaveType = models.SmallIntegerField(default=1, verbose_name='请假类型') # 1年假/2事假/3病假/4调休/5其他
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
Days = models.DecimalField(max_digits=5, decimal_places=1, default=0.0, verbose_name='请假天数')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='请假事由')
|
||||
ApproverUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1待审批/2已通过/3已驳回
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaLeave'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '请假申请'
|
||||
verbose_name_plural = '请假申请'
|
||||
|
||||
|
||||
class OaTravel(QModel):
|
||||
"""出差申请表——OA:目的地、起止时间与审批状态"""
|
||||
|
||||
OaTravelUUID = UUIDField(primary_key=True, verbose_name='出差UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='申请人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Destination = models.CharField(max_length=128, default='', verbose_name='目的地')
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
Days = models.DecimalField(max_digits=5, decimal_places=1, default=0.0, verbose_name='出差天数')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='出差事由')
|
||||
ApproverUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1待审批/2已通过/3已驳回
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaTravel'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '出差申请'
|
||||
verbose_name_plural = '出差申请'
|
||||
|
||||
|
||||
class OaMeeting(QModel):
|
||||
"""会议管理表——OA:会议安排、主持人与参会人"""
|
||||
|
||||
OaMeetingUUID = UUIDField(primary_key=True, verbose_name='会议UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Title = models.CharField(max_length=128, default='', verbose_name='会议主题')
|
||||
Location = models.CharField(max_length=128, default='', verbose_name='会议地点')
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
HostUUID = UUIDField(null=True, blank=True, verbose_name='主持人UUID')
|
||||
Attendees = models.CharField(max_length=512, default='', verbose_name='参会人')
|
||||
Summary = models.CharField(max_length=1024, default='', verbose_name='会议纪要')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaMeeting'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '会议管理'
|
||||
verbose_name_plural = '会议管理'
|
||||
|
||||
|
||||
class OaAnnouncement(QModel):
|
||||
"""公告通知表——OA:公司公告、通知发布"""
|
||||
|
||||
OaAnnouncementUUID = UUIDField(primary_key=True, verbose_name='公告UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Title = models.CharField(max_length=128, default='', verbose_name='公告标题')
|
||||
Content = models.TextField(default='', verbose_name='公告内容')
|
||||
PublisherUUID = UUIDField(null=True, blank=True, verbose_name='发布人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1已发布
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaAnnouncement'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '公告通知'
|
||||
verbose_name_plural = '公告通知'
|
||||
|
||||
|
||||
class OaReport(QModel):
|
||||
"""工作汇报表——OA:日报/周报/月报"""
|
||||
|
||||
OaReportUUID = UUIDField(primary_key=True, verbose_name='汇报UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='汇报人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReportType = models.SmallIntegerField(default=1, verbose_name='汇报类型') # 1日报/2周报/3月报
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='汇报期间')
|
||||
Content = models.TextField(default='', verbose_name='汇报内容')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaReport'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '工作汇报'
|
||||
verbose_name_plural = '工作汇报'
|
||||
@@ -0,0 +1,16 @@
|
||||
"""办公自动化流程(oa)路由
|
||||
|
||||
挂载点:`gvsds/urls.py` → `path('API/Oa/', include('gvsds.apps.gerp.apps.oa.urls'))`
|
||||
路径与 name 与拆分前保持逐字一致(前端按 name/字面路径调用,不得变更)。
|
||||
|
||||
说明:OA 的列表/增删改走通用 FormAPI(前端 CrudTable),此处仅审批相关两个专用接口。
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('PendingApprovals', views.OaPendingApprovalsView.as_view(), name='oa_pending_approvals'),
|
||||
path('Approve', views.OaApproveView.as_view(), name='oa_approve'),
|
||||
]
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from ..models import OaLeave, OaTravel
|
||||
from ..oa_manager import OaManager
|
||||
from gvsds.apps.gerp.views import ELTAuthenticatedAPIView
|
||||
from .models import OaLeave, OaTravel
|
||||
from .manager import OaManager
|
||||
|
||||
|
||||
def _CurrentUserBytes(request):
|
||||
@@ -0,0 +1 @@
|
||||
"""产品与订单(GERP 子应用)"""
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "product-order",
|
||||
"AppName": "产品与订单",
|
||||
"AppIcon": "Goods",
|
||||
"AppDesc": "产品与订单:产品模板管理与销售订单(原\"业务管理\"迁移)",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 8,
|
||||
"RequirePositionBinding": 1,
|
||||
"FrontendRoute": "/inventory/products",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "product-items",
|
||||
"MenuTitle": "产品管理",
|
||||
"MenuLink": "/inventory/products",
|
||||
"MenuIcon": "Goods",
|
||||
"MenuSort": 1,
|
||||
"ParentMenuName": "product-order"
|
||||
},
|
||||
{
|
||||
"MenuName": "product-orders",
|
||||
"MenuTitle": "销售订单",
|
||||
"MenuLink": "/sales/orders",
|
||||
"MenuIcon": "ShoppingCart",
|
||||
"MenuSort": 2,
|
||||
"ParentMenuName": "product-order"
|
||||
}
|
||||
],
|
||||
"VirtualPositions": [
|
||||
{
|
||||
"Code": "product-specialist",
|
||||
"Name": "产品专员",
|
||||
"MenuNames": ["product-items"]
|
||||
},
|
||||
{
|
||||
"Code": "order-clerk",
|
||||
"Name": "订单专员",
|
||||
"MenuNames": ["product-orders"]
|
||||
},
|
||||
{
|
||||
"Code": "business-supervisor",
|
||||
"Name": "业务主管",
|
||||
"MenuNames": ["product-items", "product-orders"]
|
||||
},
|
||||
{
|
||||
"Code": "business-manager",
|
||||
"Name": "业务经理",
|
||||
"MenuNames": ["product-items", "product-orders"]
|
||||
}
|
||||
],
|
||||
"PositionTemplates": [
|
||||
{
|
||||
"CategoryName": "业务",
|
||||
"Positions": ["产品专员", "订单专员", "业务主管", "业务经理"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""枢机协律-商店(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)"""
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "shop",
|
||||
"AppName": "枢机协律-商店",
|
||||
"AppIcon": "ShoppingCart",
|
||||
"AppDesc": "枢机协律-商店:企业内部商店与商城订单管理",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 3,
|
||||
"FrontendRoute": "/private/CreateE/shop",
|
||||
"FrontendType": "legacy",
|
||||
"LegacySrc": "pages/private/CreateE/shop.html",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "create-e-shop",
|
||||
"MenuTitle": "商店",
|
||||
"MenuLink": "pages/private/CreateE/shop.html",
|
||||
"MenuIcon": "ShoppingCart",
|
||||
"MenuSort": 4,
|
||||
"ParentMenuName": "create-e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""枢机协律-储值(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)"""
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "store-value",
|
||||
"AppName": "枢机协律-储值",
|
||||
"AppIcon": "Wallet",
|
||||
"AppDesc": "枢机协律-储值:储值卡充值与管理",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 4,
|
||||
"FrontendRoute": "/private/CreateE/StoreValue",
|
||||
"FrontendType": "legacy",
|
||||
"LegacySrc": "pages/private/CreateE/StoreValue.html",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "create-e-store-value",
|
||||
"MenuTitle": "储值查看",
|
||||
"MenuLink": "pages/private/CreateE/StoreValue.html",
|
||||
"MenuIcon": "Wallet",
|
||||
"MenuSort": 3,
|
||||
"ParentMenuName": "create-e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Talk 企业通讯(GERP 子应用)
|
||||
|
||||
按 GERP 子应用目录规约组织:models(app_label='gerp')/ manager / views / urls /
|
||||
permissions / apps.gerp_manifests 清单声明。迁移仍集中在 gvsds/apps/gerp/migrations/。
|
||||
"""
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "talk",
|
||||
"AppName": "Talk 企业通讯",
|
||||
"AppIcon": "ChatDotRound",
|
||||
"AppDesc": "企业即时通讯:单聊、群聊、图片与文件消息",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 11,
|
||||
"FrontendRoute": "/talk",
|
||||
"FrontendType": "vue",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "talk",
|
||||
"MenuTitle": "Talk",
|
||||
"MenuLink": "pages/talk/index.html",
|
||||
"MenuIcon": "ChatDotRound",
|
||||
"MenuSort": 8,
|
||||
"ParentMenuName": "company"
|
||||
}
|
||||
],
|
||||
"ConfigKeys": [
|
||||
{ "Key": "TalkGroupCompanyLimit", "Label": "公司群聊上限", "Default": "50" },
|
||||
{ "Key": "TalkGroupUserLimit", "Label": "每人群聊上限", "Default": "10" },
|
||||
{ "Key": "TalkGroupMemberLimit", "Label": "单群成员上限", "Default": "200" },
|
||||
{ "Key": "TalkCompanyMemberLimit", "Label": "公司参与人数上限", "Default": "2000" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
"""模块:Talk 企业通讯服务层(Manager)
|
||||
|
||||
本模块是 Talk 应用的唯一 ORM 入口(视图层禁止裸 ORM),并提供:
|
||||
- 会话/成员/消息的增删改查与序列化
|
||||
- 4 项配额读取与用量统计(AppConfig 可配置,公司级 → 租户默认 → manifest 默认)
|
||||
- Talk 准入判定 CheckTalkAccess(供本 app 与 socket 模块共用)
|
||||
- 消息广播 BroadcastMessage / BroadcastRead(调用 gvsdsdk.talk_socket)
|
||||
|
||||
所有方法名遵循帕斯卡命名法;UUID 一律通过 UUIDObj(x).bytes 传递。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from gvsdsdk.model_utils import UUIDObj, _GetUserModel
|
||||
|
||||
from .models import TalkConversation, TalkMember, TalkMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 会话类型
|
||||
TYPE_SINGLE = 0
|
||||
TYPE_GROUP = 1
|
||||
|
||||
# 会话状态
|
||||
STATUS_NORMAL = 1
|
||||
STATUS_DISSOLVED = 0
|
||||
|
||||
# 成员角色
|
||||
ROLE_MEMBER = 0
|
||||
ROLE_OWNER = 1
|
||||
|
||||
# 消息类型
|
||||
MSG_TEXT = 0
|
||||
MSG_IMAGE = 1
|
||||
MSG_FILE = 2
|
||||
MSG_SYSTEM = 3
|
||||
|
||||
# 附件下载地址(与既有 AssetDownloadView 路由一致)
|
||||
ASSET_DOWNLOAD_PREFIX = '/API/Asset/Download/'
|
||||
|
||||
# 4 项配额默认值(manifest 未声明或 AppConfig 未配置时回退)
|
||||
DEFAULT_QUOTAS = {
|
||||
'TalkGroupCompanyLimit': 50,
|
||||
'TalkGroupUserLimit': 10,
|
||||
'TalkGroupMemberLimit': 200,
|
||||
'TalkCompanyMemberLimit': 2000,
|
||||
}
|
||||
|
||||
# 配额中文名(超限提示用)
|
||||
QUOTA_LABELS = {
|
||||
'TalkGroupCompanyLimit': '公司群聊总数',
|
||||
'TalkGroupUserLimit': '每人可创建/加入的群聊数',
|
||||
'TalkGroupMemberLimit': '单群成员数',
|
||||
'TalkCompanyMemberLimit': '公司 Talk 参与人数',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UUID 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ToBytes(value):
|
||||
"""任意 UUID 表示 → 16 字节(无效返回 None)"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value if len(value) == 16 else None
|
||||
if hasattr(value, 'bytes'):
|
||||
return value.bytes
|
||||
try:
|
||||
return UUIDObj(str(value)).bytes
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ToStr(value):
|
||||
"""任意 UUID 表示 → 字符串(无效返回 '')"""
|
||||
if value is None:
|
||||
return ''
|
||||
if hasattr(value, 'str'):
|
||||
return value.str or ''
|
||||
if isinstance(value, bytes):
|
||||
if len(value) != 16:
|
||||
return ''
|
||||
return str(uuid.UUID(bytes=value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _NormalizeUUIDList(values):
|
||||
"""UUID 列表规范化为 bytes 列表(去重、过滤无效)"""
|
||||
result = []
|
||||
seen = set()
|
||||
for v in values or []:
|
||||
b = _ToBytes(v)
|
||||
if b and b not in seen:
|
||||
seen.add(b)
|
||||
result.append(b)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 准入判定与广播(供本 app 与 socket 模块共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def CheckTalkAccess(request_or_user_uuid, CompanyUUID=None):
|
||||
"""Talk 准入判定——满足任一即放行
|
||||
|
||||
① admin:super / admin:tenant 管理员(gvsdsdk.auth.admin_service.IsAdmin)
|
||||
② 该公司管理员(gvsdsdk.auth.admin_service.IsCompanyAdmin)
|
||||
③ 通过应用中心获得 Talk 使用权:复用既有应用访问机制
|
||||
(gvsds.apps.gerp.app_manager.AppManager.CheckAccess,走 TenantApp 安装
|
||||
/ CompanyApp 公司安装判定),或已获得个人授权(UserApp.UseStatus=1,
|
||||
即应用市场的个人授权流程;Talk 应用清单为 enterprise,但为支持
|
||||
"管理员可授权普通成员",此处同时认个人授权记录)。
|
||||
|
||||
Args:
|
||||
request_or_user_uuid: DRF request 对象,或用户 UUID(str/bytes/UUIDObj)
|
||||
CompanyUUID: 公司 UUID(可选;request 路径下缺省取当前公司上下文)
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
from gvsdsdk.auth.admin_service import IsAdmin, IsCompanyAdmin
|
||||
from gvsdsdk.auth.managers import AppManager as SdkAppManager
|
||||
|
||||
user_uuid = None
|
||||
tenant_uuid = None
|
||||
company_uuid = _ToBytes(CompanyUUID)
|
||||
|
||||
if hasattr(request_or_user_uuid, 'user') or hasattr(request_or_user_uuid, 'META'):
|
||||
request = request_or_user_uuid
|
||||
# ①② 管理员豁免
|
||||
if IsAdmin(request=request) or IsCompanyAdmin(request=request):
|
||||
return True
|
||||
user = getattr(request, 'user', None)
|
||||
user_uuid = _ToBytes(getattr(user, 'UserUUID', None))
|
||||
tenant_uuid = _ToBytes(getattr(request, 'current_tenant_uuid', None))
|
||||
if company_uuid is None:
|
||||
company_uuid = _ToBytes(getattr(request, 'current_company_uuid', None))
|
||||
else:
|
||||
user_uuid = _ToBytes(request_or_user_uuid)
|
||||
|
||||
if not user_uuid:
|
||||
return False
|
||||
|
||||
app = SdkAppManager.GetAppByCode('talk')
|
||||
if app is None:
|
||||
# 应用清单未同步(App 表无记录):仅管理员可用(已在上面放行)
|
||||
return False
|
||||
# ③-1 应用中心访问判定(租户安装 / 公司安装)
|
||||
if tenant_uuid:
|
||||
try:
|
||||
from gvsds.apps.gerp.app_manager import AppManager as GerpAppManager
|
||||
if GerpAppManager.CheckAccess(
|
||||
tenant_uuid, user_uuid, 'talk', CompanyUUID=company_uuid) is not None:
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning('Talk 应用访问判定失败', exc_info=True)
|
||||
# ③-2 个人授权(应用市场个人授权流程:UserApp.UseStatus=1)
|
||||
ua = SdkAppManager.GetUserApp(user_uuid, app.AppUUID)
|
||||
return bool(ua and ua.UseStatus == 1)
|
||||
|
||||
|
||||
def BroadcastMessage(ConversationUUID, MessageDict, SkipSid=None):
|
||||
"""向会话在线成员广播消息事件(REST 与 socket 两条发送路径共用)
|
||||
|
||||
调用 gvsdsdk.talk_socket.BroadcastToConversation;该模块缺失时安全降级
|
||||
(仅跳过推送,不影响 REST 落库结果)。
|
||||
事件名必须是 talk:message —— BroadcastToConversation 原样发送 Event 名,
|
||||
不带命名空间前缀,前端订阅的是 'talk:message'。
|
||||
"""
|
||||
try:
|
||||
from gvsdsdk.talk_socket import BroadcastToConversation
|
||||
except ImportError:
|
||||
logger.debug('gvsdsdk.talk_socket 不存在,跳过 Talk 消息广播')
|
||||
return
|
||||
try:
|
||||
BroadcastToConversation(ConversationUUID, 'talk:message', MessageDict, SkipSid=SkipSid)
|
||||
except Exception:
|
||||
logger.warning('Talk 消息广播失败', exc_info=True)
|
||||
|
||||
|
||||
def BroadcastRead(ConversationUUID, UserUUID, LastReadMessageUUID):
|
||||
"""广播会话已读位点更新事件(事件名 talk:read,理由同 BroadcastMessage)"""
|
||||
try:
|
||||
from gvsdsdk.talk_socket import BroadcastToConversation
|
||||
except ImportError:
|
||||
logger.debug('gvsdsdk.talk_socket 不存在,跳过 Talk 已读广播')
|
||||
return
|
||||
data = {
|
||||
'ConversationUUID': _ToStr(ConversationUUID),
|
||||
'UserUUID': _ToStr(UserUUID),
|
||||
'LastReadMessageUUID': _ToStr(LastReadMessageUUID),
|
||||
}
|
||||
try:
|
||||
BroadcastToConversation(ConversationUUID, 'talk:read', data)
|
||||
except Exception:
|
||||
logger.warning('Talk 已读广播失败', exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模块级便捷入口(gvsdsdk.talk_socket 按这些签名调用,勿改签名)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def SendMessageByUser(UserUUID, CompanyUUID, ConversationUUID, MsgType=0, Content='',
|
||||
AttachmentSHA1='', AttachmentName='', AttachmentSize=0,
|
||||
AttachmentMime='', SkipSid=None):
|
||||
"""socket 路径发消息:失败抛 ValueError(由 socket 层转 talk:error)
|
||||
|
||||
落库与广播统一由 TalkManager.SendMessage 完成(REST 与 socket 行为一致),
|
||||
调用方不要再重复广播,否则在线成员会收到两条相同消息。
|
||||
"""
|
||||
ok, msg, data = TalkManager.SendMessage(
|
||||
ConversationUUID, UserUUID, MsgType, Content=Content,
|
||||
AttachmentSHA1=AttachmentSHA1, AttachmentName=AttachmentName,
|
||||
AttachmentSize=AttachmentSize, AttachmentMime=AttachmentMime, SkipSid=SkipSid,
|
||||
)
|
||||
if not ok:
|
||||
raise ValueError(msg or '发送失败')
|
||||
return (data or {}).get('Message')
|
||||
|
||||
|
||||
def MarkReadByUser(UserUUID, ConversationUUID, LastReadMessageUUID):
|
||||
"""socket 路径标记已读:返回 bool(落库与广播由 TalkManager.MarkRead 完成)"""
|
||||
ok, _, _ = TalkManager.MarkRead(ConversationUUID, UserUUID, LastReadMessageUUID)
|
||||
return ok
|
||||
|
||||
|
||||
def ListConversationUUIDsForUser(UserUUID, CompanyUUID=None):
|
||||
"""当前用户会话 UUID 列表(socket 连接/鉴权后加入会话房间用)"""
|
||||
result = TalkManager.ListConversations(UserUUID, CompanyUUID)
|
||||
return [
|
||||
item.get('ConversationUUID')
|
||||
for item in (result or {}).get('List', [])
|
||||
if item.get('ConversationUUID')
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 服务层
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TalkManager:
|
||||
"""Talk 企业通讯服务层"""
|
||||
|
||||
# ==================== 配额与用量 ====================
|
||||
|
||||
@staticmethod
|
||||
def GetQuotaLimits(TenantUUID, CompanyUUID=None):
|
||||
"""读取 4 项配额上限(公司级 AppConfig → 租户默认 → manifest 默认值)
|
||||
|
||||
依据:AppManager.GetAppConfig 已实现"公司级配置 → 租户默认配置 →
|
||||
模板(manifest)默认值"的回退链,且模板键来自 Manifest.ConfigKeys。
|
||||
"""
|
||||
limits = dict(DEFAULT_QUOTAS)
|
||||
try:
|
||||
from gvsdsdk.auth.managers import AppManager as SdkAppManager
|
||||
from gvsds.apps.gerp.app_manager import AppManager as GerpAppManager
|
||||
app = SdkAppManager.GetAppByCode('talk')
|
||||
if app is not None:
|
||||
for item in GerpAppManager.GetAppConfig(TenantUUID, app.AppUUID, CompanyUUID):
|
||||
key = item.get('Key')
|
||||
if key in limits:
|
||||
raw = item.get('Value')
|
||||
try:
|
||||
limits[key] = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning('Talk 配额读取失败,使用默认值', exc_info=True)
|
||||
return limits
|
||||
|
||||
@staticmethod
|
||||
def GetUsage(CompanyUUID, UserUUID):
|
||||
"""统计当前用量:公司群聊数 / 用户群聊数 / 公司去重参与人数"""
|
||||
company_bytes = _ToBytes(CompanyUUID)
|
||||
user_bytes = _ToBytes(UserUUID)
|
||||
group_qs = TalkConversation.objects.filter(
|
||||
CompanyUUID=company_bytes, ConversationType=TYPE_GROUP, Status=STATUS_NORMAL)
|
||||
group_company_used = group_qs.count()
|
||||
group_user_used = 0
|
||||
if user_bytes:
|
||||
group_user_used = TalkMember.objects.filter(
|
||||
UserUUID=user_bytes,
|
||||
ConversationUUID__in=group_qs.values('ConversationUUID'),
|
||||
).count()
|
||||
conv_ids = TalkConversation.objects.filter(
|
||||
CompanyUUID=company_bytes, Status=STATUS_NORMAL).values('ConversationUUID')
|
||||
company_member_used = TalkMember.objects.filter(
|
||||
ConversationUUID__in=conv_ids).values('UserUUID').distinct().count()
|
||||
return {
|
||||
'GroupCompanyUsed': group_company_used,
|
||||
'GroupUserUsed': group_user_used,
|
||||
'CompanyMemberUsed': company_member_used,
|
||||
}
|
||||
|
||||
# ==================== 会话查询 ====================
|
||||
|
||||
@staticmethod
|
||||
def GetConversation(ConversationUUID):
|
||||
"""按 UUID 取会话(不存在返回 None)"""
|
||||
conv_bytes = _ToBytes(ConversationUUID)
|
||||
if conv_bytes is None:
|
||||
return None
|
||||
return TalkConversation.objects.filter(ConversationUUID=conv_bytes).first()
|
||||
|
||||
@staticmethod
|
||||
def IsMember(ConversationUUID, UserUUID):
|
||||
"""判断用户是否为会话成员"""
|
||||
conv_bytes = _ToBytes(ConversationUUID)
|
||||
user_bytes = _ToBytes(UserUUID)
|
||||
if conv_bytes is None or user_bytes is None:
|
||||
return False
|
||||
return TalkMember.objects.filter(
|
||||
ConversationUUID=conv_bytes, UserUUID=user_bytes).exists()
|
||||
|
||||
@staticmethod
|
||||
def GetMember(ConversationUUID, UserUUID):
|
||||
"""取会话成员记录(不存在返回 None)"""
|
||||
conv_bytes = _ToBytes(ConversationUUID)
|
||||
user_bytes = _ToBytes(UserUUID)
|
||||
if conv_bytes is None or user_bytes is None:
|
||||
return None
|
||||
return TalkMember.objects.filter(
|
||||
ConversationUUID=conv_bytes, UserUUID=user_bytes).first()
|
||||
|
||||
@staticmethod
|
||||
def GetUserRoleUUID(UserUUID, CompanyUUID):
|
||||
"""取用户在当前公司的角色 UUID(会话令牌签发用)"""
|
||||
user_bytes = _ToBytes(UserUUID)
|
||||
company_bytes = _ToBytes(CompanyUUID)
|
||||
if not user_bytes:
|
||||
return None
|
||||
try:
|
||||
from gvsdsdk.models import UserRole
|
||||
qs = UserRole.objects.filter(UserUUID=user_bytes)
|
||||
if company_bytes:
|
||||
row = qs.filter(CompanyUUID=company_bytes).first()
|
||||
else:
|
||||
row = qs.first()
|
||||
return _ToBytes(row.RoleUUID) if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def ListConversations(UserUUID, CompanyUUID):
|
||||
"""当前用户在某公司的会话列表(按最后消息时间倒序)
|
||||
|
||||
Returns:
|
||||
{'List': [...], 'Total': int}
|
||||
"""
|
||||
user_bytes = _ToBytes(UserUUID)
|
||||
company_bytes = _ToBytes(CompanyUUID)
|
||||
if not user_bytes or not company_bytes:
|
||||
return {'List': [], 'Total': 0}
|
||||
conv_ids = list(TalkMember.objects.filter(
|
||||
UserUUID=user_bytes, CompanyUUID=company_bytes,
|
||||
).values_list('ConversationUUID', flat=True))
|
||||
if not conv_ids:
|
||||
return {'List': [], 'Total': 0}
|
||||
convs = list(TalkConversation.objects.filter(
|
||||
ConversationUUID__in=conv_ids, Status=STATUS_NORMAL,
|
||||
).order_by('-LastMessageTime'))
|
||||
# 批量取成员与用户名,避免 N+1
|
||||
members_map = defaultdict(list)
|
||||
all_uuids = set()
|
||||
for m in TalkMember.objects.filter(ConversationUUID__in=[c.ConversationUUID for c in convs]):
|
||||
members_map[_ToStr(m.ConversationUUID)].append(m.UserUUID)
|
||||
all_uuids.add(_ToBytes(m.UserUUID))
|
||||
name_map = TalkManager._UserNames(all_uuids)
|
||||
records = [
|
||||
TalkManager._ConversationDict(conv, user_bytes, members_map, name_map)
|
||||
for conv in convs
|
||||
]
|
||||
return {'List': records, 'Total': len(records)}
|
||||
|
||||
# ==================== 会话写操作 ====================
|
||||
|
||||
@staticmethod
|
||||
def CreateConversation(CreatorUUID, CompanyUUID, TenantUUID,
|
||||
ConversationType, Title, MemberUUIDs):
|
||||
"""创建会话
|
||||
|
||||
Returns:
|
||||
(ok: bool, msg: str, data: dict|None)
|
||||
"""
|
||||
creator = _ToBytes(CreatorUUID)
|
||||
company = _ToBytes(CompanyUUID)
|
||||
tenant = _ToBytes(TenantUUID)
|
||||
if not creator or not company:
|
||||
return False, '缺少创建人或公司上下文', None
|
||||
members = _NormalizeUUIDList(MemberUUIDs)
|
||||
members = [m for m in members if m != creator]
|
||||
conv_type = TYPE_GROUP if int(ConversationType or 0) == TYPE_GROUP else TYPE_SINGLE
|
||||
|
||||
if conv_type == TYPE_SINGLE:
|
||||
return TalkManager._CreateSingle(creator, company, tenant, members)
|
||||
return TalkManager._CreateGroup(creator, company, tenant, Title, members)
|
||||
|
||||
@staticmethod
|
||||
def _CreateSingle(Creator, Company, Tenant, Members):
|
||||
"""创建/复用单聊(成员必须恰好 1 人)"""
|
||||
if len(Members) != 1:
|
||||
return False, '单聊必须且只能指定 1 位对方成员', None
|
||||
other = Members[0]
|
||||
if not TalkManager._IsCompanyUser(Company, other):
|
||||
return False, '对方成员不属于当前公司', None
|
||||
existing = TalkManager._FindSingle(Creator, other, Company)
|
||||
if existing is not None:
|
||||
return True, '', {
|
||||
'ConversationUUID': _ToStr(existing.ConversationUUID),
|
||||
'MemberCount': existing.MemberCount,
|
||||
}
|
||||
now = TalkManager._Now()
|
||||
conv = TalkConversation.objects.create(
|
||||
ConversationUUID=uuid.uuid4().bytes,
|
||||
CompanyUUID=Company,
|
||||
TenantUUID=Tenant,
|
||||
ConversationType=TYPE_SINGLE,
|
||||
Title='',
|
||||
CreatorUUID=Creator,
|
||||
MemberCount=2,
|
||||
Status=STATUS_NORMAL,
|
||||
CreateTime=now,
|
||||
)
|
||||
TalkManager._AddMemberRow(conv, Creator, ROLE_MEMBER, now)
|
||||
TalkManager._AddMemberRow(conv, other, ROLE_MEMBER, now)
|
||||
return True, '', {
|
||||
'ConversationUUID': _ToStr(conv.ConversationUUID),
|
||||
'MemberCount': 2,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _CreateGroup(Creator, Company, Tenant, Title, Members):
|
||||
"""创建群聊(校验 4 项配额)"""
|
||||
limits = TalkManager.GetQuotaLimits(Tenant, Company)
|
||||
usage = TalkManager.GetUsage(Company, Creator)
|
||||
prospective = [Creator] + Members
|
||||
|
||||
# ① 公司群聊总数上限
|
||||
if usage['GroupCompanyUsed'] + 1 > limits['TalkGroupCompanyLimit']:
|
||||
return False, f"公司群聊总数已达上限({limits['TalkGroupCompanyLimit']}),无法创建", None
|
||||
# ② 每人可创建/加入的群聊数上限(校验发起人与被邀成员)
|
||||
for uid in prospective:
|
||||
used = TalkManager._UserGroupCount(Company, uid)
|
||||
if used + 1 > limits['TalkGroupUserLimit']:
|
||||
return False, f"每人可创建/加入的群聊数已达上限({limits['TalkGroupUserLimit']}),无法创建", None
|
||||
# ③ 单群成员上限
|
||||
if len(prospective) > limits['TalkGroupMemberLimit']:
|
||||
return False, f"单群成员数超过上限({limits['TalkGroupMemberLimit']})", None
|
||||
# ④ 公司 Talk 参与人数去重上限
|
||||
new_participants = TalkManager._NewParticipantCount(Company, prospective)
|
||||
if usage['CompanyMemberUsed'] + new_participants > limits['TalkCompanyMemberLimit']:
|
||||
return False, f"公司 Talk 参与人数已达上限({limits['TalkCompanyMemberLimit']})", None
|
||||
# 成员须属于当前公司
|
||||
for uid in Members:
|
||||
if not TalkManager._IsCompanyUser(Company, uid):
|
||||
return False, '被邀成员不属于当前公司', None
|
||||
|
||||
now = TalkManager._Now()
|
||||
conv = TalkConversation.objects.create(
|
||||
ConversationUUID=uuid.uuid4().bytes,
|
||||
CompanyUUID=Company,
|
||||
TenantUUID=Tenant,
|
||||
ConversationType=TYPE_GROUP,
|
||||
Title=(Title or '').strip()[:128],
|
||||
CreatorUUID=Creator,
|
||||
MemberCount=len(prospective),
|
||||
Status=STATUS_NORMAL,
|
||||
CreateTime=now,
|
||||
)
|
||||
TalkManager._AddMemberRow(conv, Creator, ROLE_OWNER, now)
|
||||
for uid in Members:
|
||||
TalkManager._AddMemberRow(conv, uid, ROLE_MEMBER, now)
|
||||
return True, '', {
|
||||
'ConversationUUID': _ToStr(conv.ConversationUUID),
|
||||
'MemberCount': conv.MemberCount,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def InviteMembers(ConversationUUID, OperatorUUID, MemberUUIDs):
|
||||
"""邀请成员入群(校验单群人数上限与公司参与人数上限)
|
||||
|
||||
Returns:
|
||||
(ok: bool, msg: str, data: dict|None)
|
||||
"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
operator = _ToBytes(OperatorUUID)
|
||||
if conv is None or conv.Status != STATUS_NORMAL:
|
||||
return False, '会话不存在或已解散', None
|
||||
if not TalkManager.IsMember(conv.ConversationUUID, operator):
|
||||
return False, '您不是该会话成员', None
|
||||
adding = [m for m in _NormalizeUUIDList(MemberUUIDs)
|
||||
if not TalkManager.IsMember(conv.ConversationUUID, m)]
|
||||
if not adding:
|
||||
return False, '没有可邀请的新成员', None
|
||||
limits = TalkManager.GetQuotaLimits(conv.TenantUUID, conv.CompanyUUID)
|
||||
# 单群人数上限
|
||||
if conv.MemberCount + len(adding) > limits['TalkGroupMemberLimit']:
|
||||
return False, f"单群成员数超过上限({limits['TalkGroupMemberLimit']})", None
|
||||
# 公司参与人数去重上限
|
||||
usage = TalkManager.GetUsage(conv.CompanyUUID, operator)
|
||||
new_participants = TalkManager._NewParticipantCount(conv.CompanyUUID, adding)
|
||||
if usage['CompanyMemberUsed'] + new_participants > limits['TalkCompanyMemberLimit']:
|
||||
return False, f"公司 Talk 参与人数已达上限({limits['TalkCompanyMemberLimit']})", None
|
||||
for uid in adding:
|
||||
if not TalkManager._IsCompanyUser(conv.CompanyUUID, uid):
|
||||
return False, '被邀成员不属于当前公司', None
|
||||
now = TalkManager._Now()
|
||||
for uid in adding:
|
||||
TalkManager._AddMemberRow(conv, uid, ROLE_MEMBER, now)
|
||||
TalkManager._SyncMemberCount(conv)
|
||||
return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID),
|
||||
'MemberCount': conv.MemberCount}
|
||||
|
||||
@staticmethod
|
||||
def LeaveConversation(ConversationUUID, UserUUID):
|
||||
"""退出会话(群聊:退出后成员数减一;群主退出则移交给最早加入的成员)"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
user = _ToBytes(UserUUID)
|
||||
if conv is None:
|
||||
return False, '会话不存在', None
|
||||
member = TalkManager.GetMember(conv.ConversationUUID, user)
|
||||
if member is None:
|
||||
return False, '您不是该会话成员', None
|
||||
member.delete()
|
||||
if conv.ConversationType == TYPE_GROUP and member.MemberRole == ROLE_OWNER:
|
||||
successor = TalkMember.objects.filter(
|
||||
ConversationUUID=conv.ConversationUUID).order_by('JoinTime').first()
|
||||
if successor is not None:
|
||||
successor.MemberRole = ROLE_OWNER
|
||||
successor.save(update_fields=['MemberRole'])
|
||||
TalkManager._SyncMemberCount(conv)
|
||||
if conv.MemberCount <= 0:
|
||||
conv.Status = STATUS_DISSOLVED
|
||||
conv.save(update_fields=['Status'])
|
||||
return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID)}
|
||||
|
||||
@staticmethod
|
||||
def RenameConversation(ConversationUUID, OperatorUUID, Title):
|
||||
"""重命名会话(仅成员可操作)"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
operator = _ToBytes(OperatorUUID)
|
||||
if conv is None or conv.Status != STATUS_NORMAL:
|
||||
return False, '会话不存在或已解散', None
|
||||
if not TalkManager.IsMember(conv.ConversationUUID, operator):
|
||||
return False, '您不是该会话成员', None
|
||||
conv.Title = (Title or '').strip()[:128]
|
||||
conv.save(update_fields=['Title'])
|
||||
return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID), 'Title': conv.Title}
|
||||
|
||||
@staticmethod
|
||||
def DissolveConversation(ConversationUUID, OperatorUUID, IsTalkAdmin=False):
|
||||
"""解散会话(仅群主或 Talk 管理员)"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
operator = _ToBytes(OperatorUUID)
|
||||
if conv is None or conv.Status != STATUS_NORMAL:
|
||||
return False, '会话不存在或已解散', None
|
||||
member = TalkManager.GetMember(conv.ConversationUUID, operator)
|
||||
is_owner = (member is not None and member.MemberRole == ROLE_OWNER) \
|
||||
or (_ToBytes(conv.CreatorUUID) == operator)
|
||||
if not is_owner and not IsTalkAdmin:
|
||||
return False, '仅群主或 Talk 管理员可解散该会话', None
|
||||
conv.Status = STATUS_DISSOLVED
|
||||
conv.save(update_fields=['Status'])
|
||||
return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID)}
|
||||
|
||||
# ==================== 消息 ====================
|
||||
|
||||
@staticmethod
|
||||
def ListMessages(ConversationUUID, UserUUID, PageIndex=1, PageSize=20, BeforeTime=None):
|
||||
"""拉取会话消息(Records 按时间升序)
|
||||
|
||||
BeforeTime 非空时向上翻页(取早于该时间的消息);否则按 PageIndex 从最新往回取。
|
||||
|
||||
Returns:
|
||||
(ok: bool, msg: str, data: dict|None)
|
||||
"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
user = _ToBytes(UserUUID)
|
||||
if conv is None:
|
||||
return False, '会话不存在', None
|
||||
if not TalkManager.IsMember(conv.ConversationUUID, user):
|
||||
return False, '您不是该会话成员', None
|
||||
try:
|
||||
page_index = max(int(PageIndex or 1), 1)
|
||||
except (TypeError, ValueError):
|
||||
page_index = 1
|
||||
try:
|
||||
page_size = min(max(int(PageSize or 20), 1), 200)
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = TalkMessage.objects.filter(ConversationUUID=conv.ConversationUUID)
|
||||
total = qs.count()
|
||||
if BeforeTime:
|
||||
qs = qs.filter(CreateTime__lt=BeforeTime)
|
||||
start = (page_index - 1) * page_size
|
||||
# 先按时间倒序截取,再反转为升序返回(前端向上翻页稳定)
|
||||
items = list(qs.order_by('-CreateTime', '-MessageUUID')[start:start + page_size])
|
||||
items.reverse()
|
||||
name_map = TalkManager._UserNames({_ToBytes(m.SenderUUID) for m in items})
|
||||
records = [TalkManager._MessageDict(m, name_map) for m in items]
|
||||
return True, '', {'Records': records, 'Total': total}
|
||||
|
||||
@staticmethod
|
||||
def SendMessage(ConversationUUID, SenderUUID, MsgType, Content='',
|
||||
AttachmentSHA1='', AttachmentName='', AttachmentSize=0,
|
||||
AttachmentMime='', SkipSid=None):
|
||||
"""发送消息并广播(REST 与 socket 两条路径行为一致)
|
||||
|
||||
Returns:
|
||||
(ok: bool, msg: str, data: dict|None) data={'Message': {...}}
|
||||
"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
sender = _ToBytes(SenderUUID)
|
||||
if conv is None or conv.Status != STATUS_NORMAL:
|
||||
return False, '会话不存在或已解散', None
|
||||
if not TalkManager.IsMember(conv.ConversationUUID, sender):
|
||||
return False, '您不是该会话成员', None
|
||||
try:
|
||||
msg_type = int(MsgType or 0)
|
||||
except (TypeError, ValueError):
|
||||
msg_type = MSG_TEXT
|
||||
if msg_type not in (MSG_TEXT, MSG_IMAGE, MSG_FILE, MSG_SYSTEM):
|
||||
msg_type = MSG_TEXT
|
||||
content = Content or ''
|
||||
sha1 = (AttachmentSHA1 or '').strip()
|
||||
mime = (AttachmentMime or '').strip()
|
||||
now = TalkManager._Now()
|
||||
msg = TalkMessage.objects.create(
|
||||
MessageUUID=uuid.uuid4().bytes,
|
||||
ConversationUUID=conv.ConversationUUID,
|
||||
CompanyUUID=conv.CompanyUUID,
|
||||
SenderUUID=sender,
|
||||
MsgType=msg_type,
|
||||
Content=content,
|
||||
AttachmentSHA1=sha1,
|
||||
AttachmentName=(AttachmentName or '')[:256],
|
||||
AttachmentSize=int(AttachmentSize or 0),
|
||||
AttachmentMime=mime[:128],
|
||||
CreateTime=now,
|
||||
)
|
||||
# 会话摘要/最后消息回填
|
||||
conv.LastMessageUUID = msg.MessageUUID
|
||||
conv.LastMessageTime = now
|
||||
conv.LastMessagePreview = TalkManager._Preview(msg)
|
||||
conv.save(update_fields=['LastMessageUUID', 'LastMessageTime', 'LastMessagePreview'])
|
||||
name_map = TalkManager._UserNames({sender})
|
||||
message_dict = TalkManager._MessageDict(msg, name_map)
|
||||
# 广播给在线成员(socket 模块缺失时安全降级)
|
||||
BroadcastMessage(conv.ConversationUUID, message_dict, SkipSid=SkipSid)
|
||||
return True, '', {'Message': message_dict}
|
||||
|
||||
@staticmethod
|
||||
def MarkRead(ConversationUUID, UserUUID, LastReadMessageUUID):
|
||||
"""更新本人已读位点并广播"""
|
||||
conv = TalkManager.GetConversation(ConversationUUID)
|
||||
user = _ToBytes(UserUUID)
|
||||
if conv is None:
|
||||
return False, '会话不存在', None
|
||||
member = TalkManager.GetMember(conv.ConversationUUID, user)
|
||||
if member is None:
|
||||
return False, '您不是该会话成员', None
|
||||
last_bytes = _ToBytes(LastReadMessageUUID)
|
||||
now = TalkManager._Now()
|
||||
member.LastReadTime = now
|
||||
if last_bytes is not None:
|
||||
member.LastReadMessageUUID = last_bytes
|
||||
member.save(update_fields=['LastReadTime', 'LastReadMessageUUID'])
|
||||
BroadcastRead(conv.ConversationUUID, user, last_bytes)
|
||||
return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID),
|
||||
'LastReadMessageUUID': _ToStr(last_bytes)}
|
||||
|
||||
# ==================== 成员搜索 ====================
|
||||
|
||||
@staticmethod
|
||||
def SearchCompanyUsers(CompanyUUID, Keyword=''):
|
||||
"""按关键字搜索当前公司成员(UserName 模糊匹配,最多 50 条)"""
|
||||
company_bytes = _ToBytes(CompanyUUID)
|
||||
if not company_bytes:
|
||||
return []
|
||||
from gvsdsdk.auth.managers import CompanyManager
|
||||
company_user_uuids = list(CompanyManager.GetCompanyUserUUIDs(company_bytes))
|
||||
if not company_user_uuids:
|
||||
return []
|
||||
User = _GetUserModel()
|
||||
qs = User.objects.filter(UserUUID__in=company_user_uuids)
|
||||
keyword = (Keyword or '').strip()
|
||||
if keyword:
|
||||
qs = qs.filter(UserName__icontains=keyword)
|
||||
users = qs.order_by('UserName')[:50]
|
||||
return [
|
||||
{
|
||||
'UserUUID': _ToStr(u.UserUUID),
|
||||
'UserName': u.UserName or '',
|
||||
'DisplayName': u.UserName or '',
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
# ==================== 内部辅助 ====================
|
||||
|
||||
@staticmethod
|
||||
def _Now():
|
||||
return TalkConversation._meta.get_field('CreateTime').default()
|
||||
|
||||
@staticmethod
|
||||
def _AddMemberRow(Conversation, UserUUIDBytes, Role, JoinTime):
|
||||
TalkMember.objects.create(
|
||||
MemberUUID=uuid.uuid4().bytes,
|
||||
ConversationUUID=Conversation.ConversationUUID,
|
||||
CompanyUUID=Conversation.CompanyUUID,
|
||||
UserUUID=UserUUIDBytes,
|
||||
MemberRole=Role,
|
||||
JoinTime=JoinTime,
|
||||
MuteFlag=0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _SyncMemberCount(Conversation):
|
||||
count = TalkMember.objects.filter(
|
||||
ConversationUUID=Conversation.ConversationUUID).count()
|
||||
Conversation.MemberCount = count
|
||||
Conversation.save(update_fields=['MemberCount'])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def _UserGroupCount(CompanyUUID, UserUUIDBytes):
|
||||
"""用户在某公司的群聊数"""
|
||||
return TalkMember.objects.filter(
|
||||
UserUUID=UserUUIDBytes,
|
||||
ConversationUUID__in=TalkConversation.objects.filter(
|
||||
CompanyUUID=CompanyUUID, ConversationType=TYPE_GROUP,
|
||||
Status=STATUS_NORMAL,
|
||||
).values('ConversationUUID'),
|
||||
).count()
|
||||
|
||||
@staticmethod
|
||||
def _NewParticipantCount(CompanyUUID, UserUUIDs):
|
||||
"""给定用户中尚未参与该公司 Talk 会话的人数(去重)"""
|
||||
uuids = _NormalizeUUIDList(UserUUIDs)
|
||||
if not uuids:
|
||||
return 0
|
||||
conv_ids = TalkConversation.objects.filter(
|
||||
CompanyUUID=CompanyUUID, Status=STATUS_NORMAL).values('ConversationUUID')
|
||||
existing = set(TalkMember.objects.filter(
|
||||
ConversationUUID__in=conv_ids, UserUUID__in=uuids,
|
||||
).values_list('UserUUID', flat=True))
|
||||
existing = {_ToBytes(e) for e in existing}
|
||||
return sum(1 for u in uuids if u not in existing)
|
||||
|
||||
@staticmethod
|
||||
def _IsCompanyUser(CompanyUUID, UserUUIDBytes):
|
||||
"""判断用户是否属于该公司"""
|
||||
if CompanyUUID is None or UserUUIDBytes is None:
|
||||
return False
|
||||
from gvsdsdk.auth.managers import CompanyManager
|
||||
return UserUUIDBytes in CompanyManager.GetCompanyUserUUIDs(CompanyUUID)
|
||||
|
||||
@staticmethod
|
||||
def _FindSingle(UserA, UserB, CompanyUUID):
|
||||
"""查找两人已存在的单聊会话(不存在返回 None)"""
|
||||
conv_ids = TalkConversation.objects.filter(
|
||||
CompanyUUID=CompanyUUID, ConversationType=TYPE_SINGLE,
|
||||
Status=STATUS_NORMAL,
|
||||
).values_list('ConversationUUID', flat=True)
|
||||
pairs = defaultdict(set)
|
||||
rows = TalkMember.objects.filter(
|
||||
ConversationUUID__in=list(conv_ids),
|
||||
UserUUID__in=[UserA, UserB],
|
||||
).values_list('ConversationUUID', 'UserUUID')
|
||||
for cid, uid in rows:
|
||||
pairs[_ToBytes(cid)].add(_ToBytes(uid))
|
||||
for cid, uids in pairs.items():
|
||||
if UserA in uids and UserB in uids:
|
||||
if TalkMember.objects.filter(ConversationUUID=cid).count() == 2:
|
||||
return TalkConversation.objects.filter(ConversationUUID=cid).first()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _UserNames(UserUUIDs):
|
||||
"""批量取用户名映射:{bytes: UserName}"""
|
||||
from gvsdsdk.auth.managers import AuthManager
|
||||
return AuthManager.GetUserNames([u for u in (UserUUIDs or []) if u])
|
||||
|
||||
@staticmethod
|
||||
def _Preview(Message):
|
||||
"""会话摘要(最后消息预览)"""
|
||||
if Message.MsgType == MSG_IMAGE:
|
||||
return '[图片]'
|
||||
if Message.MsgType == MSG_FILE:
|
||||
return f'[文件] {Message.AttachmentName}'.strip()
|
||||
preview = (Message.Content or '').replace('\n', ' ')
|
||||
return preview[:256]
|
||||
|
||||
@staticmethod
|
||||
def _ConversationDict(Conversation, CurrentUserBytes, MembersMap, NameMap):
|
||||
"""会话 → 视图 dict(单聊标题空则用对方展示名)"""
|
||||
conv_key = _ToStr(Conversation.ConversationUUID)
|
||||
member_uuids = MembersMap.get(conv_key, [])
|
||||
members = []
|
||||
for uid in member_uuids:
|
||||
ub = _ToBytes(uid)
|
||||
name = NameMap.get(ub, '')
|
||||
members.append({
|
||||
'UserUUID': _ToStr(ub),
|
||||
'UserName': name,
|
||||
'DisplayName': name,
|
||||
})
|
||||
title = Conversation.Title or ''
|
||||
if not title and Conversation.ConversationType == TYPE_SINGLE:
|
||||
for uid in member_uuids:
|
||||
ub = _ToBytes(uid)
|
||||
if ub != CurrentUserBytes:
|
||||
title = NameMap.get(ub, '')
|
||||
break
|
||||
member = TalkManager.GetMember(Conversation.ConversationUUID, CurrentUserBytes)
|
||||
unread_qs = TalkMessage.objects.filter(ConversationUUID=Conversation.ConversationUUID)
|
||||
if member is not None and member.LastReadTime is not None:
|
||||
unread_qs = unread_qs.filter(CreateTime__gt=member.LastReadTime)
|
||||
if CurrentUserBytes is not None:
|
||||
unread_qs = unread_qs.exclude(SenderUUID=CurrentUserBytes)
|
||||
return {
|
||||
'ConversationUUID': conv_key,
|
||||
'ConversationType': Conversation.ConversationType,
|
||||
'Title': title,
|
||||
'MemberCount': Conversation.MemberCount,
|
||||
'LastMessageTime': Conversation.LastMessageTime.isoformat() if Conversation.LastMessageTime else None,
|
||||
'LastMessagePreview': Conversation.LastMessagePreview or '',
|
||||
'UnreadCount': unread_qs.count(),
|
||||
'Members': members,
|
||||
# 群主标记:前端据此对「非管理员群主」显示解散入口(管理员另有 Entry.IsAdmin)
|
||||
'IsOwner': bool(member is not None and member.MemberRole == ROLE_OWNER),
|
||||
'OwnerUUID': _ToStr(Conversation.CreatorUUID),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _MessageDict(Message, NameMap):
|
||||
"""消息 → 视图 dict"""
|
||||
sha1 = Message.AttachmentSHA1 or ''
|
||||
mime = Message.AttachmentMime or ''
|
||||
attachment = None
|
||||
if sha1:
|
||||
attachment = {
|
||||
'SHA1': sha1,
|
||||
'Name': Message.AttachmentName or '',
|
||||
'Size': Message.AttachmentSize or 0,
|
||||
'Mime': mime,
|
||||
'Url': f'{ASSET_DOWNLOAD_PREFIX}{sha1}',
|
||||
'IsImage': bool(mime.startswith('image/')),
|
||||
}
|
||||
return {
|
||||
'MessageUUID': _ToStr(Message.MessageUUID),
|
||||
'ConversationUUID': _ToStr(Message.ConversationUUID),
|
||||
'SenderUUID': _ToStr(Message.SenderUUID),
|
||||
'SenderName': NameMap.get(_ToBytes(Message.SenderUUID), ''),
|
||||
'MsgType': Message.MsgType,
|
||||
'Content': Message.Content or '',
|
||||
'Attachment': attachment,
|
||||
'CreateTime': Message.CreateTime.isoformat() if Message.CreateTime else None,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""模块:Talk 企业通讯数据模型
|
||||
|
||||
三张表:
|
||||
TalkConversation —— 会话(单聊 ConversationType=0 / 群聊 ConversationType=1)
|
||||
TalkMember —— 会话成员与已读位点
|
||||
TalkMessage —— 会话消息(文本/图片/文件/系统)
|
||||
|
||||
所有字段遵循帕斯卡命名法,显式声明 db_column;主键为 UUIDField(BINARY(16))。
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from gvsds.apps.core.models import UUIDField
|
||||
from gvsds.model_base import QModel
|
||||
|
||||
|
||||
class TalkConversation(QModel):
|
||||
"""会话表——单聊与群聊的统一实体"""
|
||||
|
||||
ConversationUUID = UUIDField(primary_key=True, db_column='ConversationUUID', verbose_name='会话UUID')
|
||||
CompanyUUID = UUIDField(db_index=True, db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
# 会话类型:0=单聊 1=群聊
|
||||
ConversationType = models.SmallIntegerField(default=0, db_column='ConversationType', verbose_name='会话类型(0单聊/1群聊)')
|
||||
Title = models.CharField(max_length=128, default='', db_column='Title', verbose_name='会话标题')
|
||||
CreatorUUID = UUIDField(db_column='CreatorUUID', verbose_name='创建人UUID')
|
||||
MemberCount = models.IntegerField(default=0, db_column='MemberCount', verbose_name='成员数')
|
||||
LastMessageUUID = UUIDField(null=True, blank=True, db_column='LastMessageUUID', verbose_name='最后消息UUID')
|
||||
LastMessageTime = models.DateTimeField(null=True, blank=True, db_column='LastMessageTime', verbose_name='最后消息时间')
|
||||
LastMessagePreview = models.CharField(max_length=256, default='', db_column='LastMessagePreview', verbose_name='最后消息摘要')
|
||||
# 会话状态:1=正常 0=已解散
|
||||
Status = models.SmallIntegerField(default=1, db_column='Status', verbose_name='状态(1正常/0解散)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'TalkConversation'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-LastMessageTime']
|
||||
verbose_name = 'Talk会话'
|
||||
verbose_name_plural = 'Talk会话'
|
||||
|
||||
def __str__(self):
|
||||
return f'TalkConversation[{self.Title or self.ConversationUUID}]'
|
||||
|
||||
|
||||
class TalkMember(QModel):
|
||||
"""会话成员表——成员归属与已读位点"""
|
||||
|
||||
MemberUUID = UUIDField(primary_key=True, db_column='MemberUUID', verbose_name='成员UUID')
|
||||
ConversationUUID = UUIDField(db_index=True, db_column='ConversationUUID', verbose_name='会话UUID')
|
||||
CompanyUUID = UUIDField(db_index=True, db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID')
|
||||
# 成员角色:0=成员 1=群主
|
||||
MemberRole = models.SmallIntegerField(default=0, db_column='MemberRole', verbose_name='成员角色(0成员/1群主)')
|
||||
JoinTime = models.DateTimeField(default=datetime.datetime.utcnow, db_column='JoinTime', verbose_name='加入时间')
|
||||
LastReadTime = models.DateTimeField(null=True, blank=True, db_column='LastReadTime', verbose_name='最后已读时间')
|
||||
LastReadMessageUUID = UUIDField(null=True, blank=True, db_column='LastReadMessageUUID', verbose_name='最后已读消息UUID')
|
||||
MuteFlag = models.SmallIntegerField(default=0, db_column='MuteFlag', verbose_name='免打扰(0/1)')
|
||||
|
||||
class Meta:
|
||||
db_table = 'TalkMember'
|
||||
app_label = 'gerp'
|
||||
unique_together = (('ConversationUUID', 'UserUUID'),)
|
||||
verbose_name = 'Talk会话成员'
|
||||
verbose_name_plural = 'Talk会话成员'
|
||||
|
||||
def __str__(self):
|
||||
return f'TalkMember[{self.ConversationUUID}/{self.UserUUID}]'
|
||||
|
||||
|
||||
class TalkMessage(QModel):
|
||||
"""会话消息表——文本/图片/文件/系统消息"""
|
||||
|
||||
MessageUUID = UUIDField(primary_key=True, db_column='MessageUUID', verbose_name='消息UUID')
|
||||
ConversationUUID = UUIDField(db_index=True, db_column='ConversationUUID', verbose_name='会话UUID')
|
||||
CompanyUUID = UUIDField(db_index=True, db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
SenderUUID = UUIDField(db_index=True, db_column='SenderUUID', verbose_name='发送人UUID')
|
||||
# 消息类型:0=文本 1=图片 2=文件 3=系统
|
||||
MsgType = models.SmallIntegerField(default=0, db_column='MsgType', verbose_name='消息类型(0文本/1图片/2文件/3系统)')
|
||||
Content = models.TextField(default='', db_column='Content', verbose_name='消息内容')
|
||||
AttachmentSHA1 = models.CharField(max_length=40, default='', db_column='AttachmentSHA1', verbose_name='附件SHA1')
|
||||
AttachmentName = models.CharField(max_length=256, default='', db_column='AttachmentName', verbose_name='附件文件名')
|
||||
AttachmentSize = models.BigIntegerField(default=0, db_column='AttachmentSize', verbose_name='附件大小')
|
||||
AttachmentMime = models.CharField(max_length=128, default='', db_column='AttachmentMime', verbose_name='附件MIME')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'TalkMessage'
|
||||
app_label = 'gerp'
|
||||
ordering = ['CreateTime']
|
||||
verbose_name = 'Talk消息'
|
||||
verbose_name_plural = 'Talk消息'
|
||||
|
||||
def __str__(self):
|
||||
return f'TalkMessage[{self.MessageUUID}]'
|
||||
@@ -0,0 +1,21 @@
|
||||
"""模块:Talk 应用权限类
|
||||
|
||||
IsTalkUser —— 登录且通过 Talk 准入判定(CheckTalkAccess)。
|
||||
准入的实际依据见 manager.CheckTalkAccess(管理员 / 应用中心授权 / 个人授权)。
|
||||
"""
|
||||
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
from .manager import CheckTalkAccess
|
||||
|
||||
|
||||
class IsTalkUser(BasePermission):
|
||||
"""Talk 使用权限——需登录且获得 Talk 使用权"""
|
||||
|
||||
message = 'Talk 需要管理员授权后使用'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, 'user', None)
|
||||
if user is None or not getattr(user, 'is_authenticated', False):
|
||||
return False
|
||||
return CheckTalkAccess(request)
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('Entry', views.EntryView.as_view()),
|
||||
path('Conversations', views.ConversationListView.as_view()),
|
||||
path('Conversation/Create', views.ConversationCreateView.as_view()),
|
||||
path('Conversation/Invite', views.ConversationInviteView.as_view()),
|
||||
path('Conversation/Leave', views.ConversationLeaveView.as_view()),
|
||||
path('Conversation/Rename', views.ConversationRenameView.as_view()),
|
||||
path('Conversation/Dissolve', views.ConversationDissolveView.as_view()),
|
||||
path('Messages', views.MessageListView.as_view()),
|
||||
path('Send', views.SendView.as_view()),
|
||||
path('Read', views.ReadView.as_view()),
|
||||
path('Members/Search', views.MemberSearchView.as_view()),
|
||||
path('Upload', views.UploadView.as_view()),
|
||||
path('SocketToken', views.SocketTokenView.as_view()),
|
||||
]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""模块:Talk 企业通讯视图
|
||||
|
||||
REST 接口(统一挂在 API/Talk/ 前缀下):
|
||||
GET Entry 准入与配额/用量概览
|
||||
GET Conversations 会话列表
|
||||
POST Conversation/Create 创建单聊/群聊
|
||||
POST Conversation/Invite 邀请成员
|
||||
POST Conversation/Leave 退出会话
|
||||
POST Conversation/Rename 重命名会话
|
||||
POST Conversation/Dissolve 解散会话
|
||||
GET Messages 拉取消息(升序,BeforeTime 向上翻页)
|
||||
POST Send 发送消息(落库后广播)
|
||||
POST Read 更新本人已读位点(并广播)
|
||||
GET Members/Search 搜索当前公司成员
|
||||
POST Upload 上传图片/文件附件
|
||||
GET SocketToken 签发 Talk socket 令牌
|
||||
|
||||
所有视图权限:IsAuthenticated + IsTalkUser。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from gvsdsdk.auth.admin_service import IsAdmin, IsCompanyAdmin
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
|
||||
from .manager import CheckTalkAccess, TalkManager
|
||||
from .permissions import IsTalkUser
|
||||
|
||||
# 附件上传大小上限:10MB
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# socket 令牌有效期(秒)——与 gvsdsdk.talk_socket 的实际 TTL 保持一致,
|
||||
# 避免前端按错误的过期时间刷新令牌(取值失败时回退 12 小时)
|
||||
try:
|
||||
from gvsdsdk.talk_socket import TALK_TOKEN_TTL_HOURS as _TALK_TTL_HOURS
|
||||
except Exception: # pragma: no cover - socket 模块缺失时仅影响提示值
|
||||
_TALK_TTL_HOURS = 12
|
||||
SOCKET_TOKEN_EXPIRES_IN = int(_TALK_TTL_HOURS * 3600)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上下文解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ToBytes(value):
|
||||
"""任意 UUID 表示 → 16 字节(无效返回 None)"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value if len(value) == 16 else None
|
||||
if hasattr(value, 'bytes'):
|
||||
return value.bytes
|
||||
try:
|
||||
return UUIDObj(str(value)).bytes
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ToStr(value):
|
||||
"""任意 UUID 表示 → 字符串(无效返回 '')"""
|
||||
if value is None:
|
||||
return ''
|
||||
if hasattr(value, 'str'):
|
||||
return value.str or ''
|
||||
if isinstance(value, bytes):
|
||||
return str(uuid.UUID(bytes=value)) if len(value) == 16 else ''
|
||||
return str(value)
|
||||
|
||||
|
||||
def _UserBytes(request):
|
||||
"""当前用户 UUID bytes"""
|
||||
user = getattr(request, 'user', None)
|
||||
return _ToBytes(getattr(user, 'UserUUID', None)) if user else None
|
||||
|
||||
|
||||
def _TenantBytes(request):
|
||||
"""当前角色租户 UUID bytes(CurrentRoleMiddleware 注入)"""
|
||||
return _ToBytes(getattr(request, 'current_tenant_uuid', None))
|
||||
|
||||
|
||||
def _CompanyBytes(request):
|
||||
"""当前角色公司 UUID bytes(CurrentRoleMiddleware 注入)"""
|
||||
return _ToBytes(getattr(request, 'current_company_uuid', None))
|
||||
|
||||
|
||||
def _RoleBytes(request):
|
||||
"""当前角色 UUID bytes(Cookie CurrentRoleUUID)"""
|
||||
return _ToBytes(request.COOKIES.get('CurrentRoleUUID', ''))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 视图基类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TalkAPIView(APIView):
|
||||
"""Talk 视图基类——统一鉴权与权限不足响应体
|
||||
|
||||
permission_classes 为 [IsAuthenticated, IsTalkUser];准入失败时返回
|
||||
403 {'code': 1, 'msg': 'Talk 需要管理员授权后使用'}(覆盖默认异常格式化)。
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsTalkUser]
|
||||
|
||||
def handle_exception(self, exc):
|
||||
if isinstance(exc, PermissionDenied):
|
||||
return Response({'code': 1, 'msg': 'Talk 需要管理员授权后使用'}, status=403)
|
||||
return super().handle_exception(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 视图
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EntryView(TalkAPIView):
|
||||
"""GET API/Talk/Entry —— 准入与配额/用量概览"""
|
||||
|
||||
def get(self, request):
|
||||
user = _UserBytes(request)
|
||||
company = _CompanyBytes(request)
|
||||
tenant = _TenantBytes(request)
|
||||
limits = TalkManager.GetQuotaLimits(tenant, company)
|
||||
if company:
|
||||
usage = TalkManager.GetUsage(company, user)
|
||||
else:
|
||||
usage = {'GroupCompanyUsed': 0, 'GroupUserUsed': 0, 'CompanyMemberUsed': 0}
|
||||
is_admin = IsAdmin(request=request) or IsCompanyAdmin(request=request)
|
||||
return Response({'code': 0, 'data': {
|
||||
'CanUse': CheckTalkAccess(request),
|
||||
'IsAdmin': is_admin,
|
||||
'CompanyUUID': _ToStr(company),
|
||||
'Quotas': {
|
||||
'GroupCompanyLimit': limits['TalkGroupCompanyLimit'],
|
||||
'GroupUserLimit': limits['TalkGroupUserLimit'],
|
||||
'GroupMemberLimit': limits['TalkGroupMemberLimit'],
|
||||
'CompanyMemberLimit': limits['TalkCompanyMemberLimit'],
|
||||
},
|
||||
'Usage': {
|
||||
'GroupCompanyUsed': usage['GroupCompanyUsed'],
|
||||
'GroupUserUsed': usage['GroupUserUsed'],
|
||||
'CompanyMemberUsed': usage['CompanyMemberUsed'],
|
||||
},
|
||||
}})
|
||||
|
||||
|
||||
class ConversationListView(TalkAPIView):
|
||||
"""GET API/Talk/Conversations —— 会话列表(按最后消息时间倒序)"""
|
||||
|
||||
def get(self, request):
|
||||
user = _UserBytes(request)
|
||||
company = _CompanyBytes(request)
|
||||
if not company:
|
||||
return Response({'code': 1, 'msg': '缺少公司上下文,请先切换角色'}, status=400)
|
||||
result = TalkManager.ListConversations(user, company)
|
||||
return Response({'code': 0, 'data': result})
|
||||
|
||||
|
||||
class ConversationCreateView(TalkAPIView):
|
||||
"""POST API/Talk/Conversation/Create —— 创建单聊/群聊"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
company = _CompanyBytes(request)
|
||||
tenant = _TenantBytes(request)
|
||||
if not company:
|
||||
return Response({'code': 1, 'msg': '缺少公司上下文,请先切换角色'}, status=400)
|
||||
conv_type = request.data.get('ConversationType', 0)
|
||||
title = request.data.get('Title', '')
|
||||
members = request.data.get('MemberUUIDs') or []
|
||||
ok, msg, data = TalkManager.CreateConversation(
|
||||
user, company, tenant, conv_type, title, members)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class ConversationInviteView(TalkAPIView):
|
||||
"""POST API/Talk/Conversation/Invite —— 邀请成员入群"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
members = request.data.get('MemberUUIDs') or []
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
ok, msg, data = TalkManager.InviteMembers(conv_uuid, user, members)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class ConversationLeaveView(TalkAPIView):
|
||||
"""POST API/Talk/Conversation/Leave —— 退出会话"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
ok, msg, data = TalkManager.LeaveConversation(conv_uuid, user)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class ConversationRenameView(TalkAPIView):
|
||||
"""POST API/Talk/Conversation/Rename —— 重命名会话"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
title = request.data.get('Title', '')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
ok, msg, data = TalkManager.RenameConversation(conv_uuid, user, title)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class ConversationDissolveView(TalkAPIView):
|
||||
"""POST API/Talk/Conversation/Dissolve —— 解散会话(仅群主或 Talk 管理员)"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
is_admin = IsAdmin(request=request) or IsCompanyAdmin(request=request)
|
||||
ok, msg, data = TalkManager.DissolveConversation(conv_uuid, user, IsTalkAdmin=is_admin)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class MessageListView(TalkAPIView):
|
||||
"""GET API/Talk/Messages —— 拉取会话消息(升序,BeforeTime 向上翻页)"""
|
||||
|
||||
def get(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.query_params.get('ConversationUUID')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
before = request.query_params.get('BeforeTime') or None
|
||||
before_dt = parse_datetime(before) if before else None
|
||||
ok, msg, data = TalkManager.ListMessages(
|
||||
conv_uuid, user,
|
||||
PageIndex=request.query_params.get('PageIndex', 1),
|
||||
PageSize=request.query_params.get('PageSize', 20),
|
||||
BeforeTime=before_dt,
|
||||
)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class SendView(TalkAPIView):
|
||||
"""POST API/Talk/Send —— 发送消息(落库后广播)"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
ok, msg, data = TalkManager.SendMessage(
|
||||
conv_uuid, user,
|
||||
MsgType=request.data.get('MsgType', 0),
|
||||
Content=request.data.get('Content', ''),
|
||||
AttachmentSHA1=request.data.get('AttachmentSHA1', ''),
|
||||
AttachmentName=request.data.get('AttachmentName', ''),
|
||||
AttachmentSize=request.data.get('AttachmentSize', 0),
|
||||
AttachmentMime=request.data.get('AttachmentMime', ''),
|
||||
)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class ReadView(TalkAPIView):
|
||||
"""POST API/Talk/Read —— 更新本人已读位点(并广播)"""
|
||||
|
||||
def post(self, request):
|
||||
user = _UserBytes(request)
|
||||
conv_uuid = request.data.get('ConversationUUID')
|
||||
last_read = request.data.get('LastReadMessageUUID')
|
||||
if not conv_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400)
|
||||
ok, msg, data = TalkManager.MarkRead(conv_uuid, user, last_read)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': msg}, status=400)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
class MemberSearchView(TalkAPIView):
|
||||
"""GET API/Talk/Members/Search —— 搜索当前公司成员"""
|
||||
|
||||
def get(self, request):
|
||||
company = _CompanyBytes(request)
|
||||
if not company:
|
||||
return Response({'code': 1, 'msg': '缺少公司上下文,请先切换角色'}, status=400)
|
||||
keyword = request.query_params.get('Keyword', '')
|
||||
users = TalkManager.SearchCompanyUsers(company, keyword)
|
||||
return Response({'code': 0, 'data': {'Users': users}})
|
||||
|
||||
|
||||
class UploadView(TalkAPIView):
|
||||
"""POST API/Talk/Upload —— 上传附件(multipart,字段名 file,≤10MB)
|
||||
|
||||
复用 AssetManager.EnsureAsset 落 FileAsset 表;落盘方式与既有
|
||||
gvsds/apps/gerp/views/asset.py 的 AssetSimpleUploadView 保持一致。
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
upload_file = request.FILES.get('file')
|
||||
if not upload_file:
|
||||
return Response({'code': 1, 'msg': '缺少文件'}, status=400)
|
||||
if upload_file.size > MAX_UPLOAD_SIZE:
|
||||
return Response({'code': 1, 'msg': '文件超过 10MB 上限'}, status=400)
|
||||
file_name = upload_file.name
|
||||
mime_type = upload_file.content_type or 'application/octet-stream'
|
||||
user_uuid = _UserBytes(request)
|
||||
company_uuid = _CompanyBytes(request)
|
||||
|
||||
sha1 = hashlib.sha1()
|
||||
final_dir = os.path.join(settings.MEDIA_ROOT, 'assets')
|
||||
os.makedirs(final_dir, exist_ok=True)
|
||||
upload_id = str(uuid.uuid4()).replace('-', '')
|
||||
final_path = os.path.join(final_dir, f'{upload_id}_{file_name}')
|
||||
total_size = 0
|
||||
with open(final_path, 'wb') as out_f:
|
||||
for chunk in upload_file.chunks():
|
||||
sha1.update(chunk)
|
||||
out_f.write(chunk)
|
||||
total_size += len(chunk)
|
||||
file_sha1 = sha1.hexdigest()
|
||||
|
||||
from gvsdsdk.auth.managers import AssetManager
|
||||
asset, created = AssetManager.EnsureAsset(
|
||||
file_sha1, file_name, total_size, mime_type, final_path,
|
||||
UploaderUUID=user_uuid, CompanyUUID=company_uuid,
|
||||
)
|
||||
if not created:
|
||||
os.remove(final_path)
|
||||
is_image = bool(asset.MimeType and asset.MimeType.startswith('image/'))
|
||||
return Response({'code': 0, 'data': {
|
||||
'SHA1': file_sha1,
|
||||
'Name': asset.FileName,
|
||||
'Size': asset.FileSize,
|
||||
'Mime': asset.MimeType,
|
||||
'IsImage': is_image,
|
||||
'Url': f'/API/Asset/Download/{file_sha1}',
|
||||
}})
|
||||
|
||||
|
||||
class SocketTokenView(TalkAPIView):
|
||||
"""GET API/Talk/SocketToken —— 签发 Talk socket 令牌
|
||||
|
||||
委托 gvsdsdk.talk_socket.IssueUserToken 生成 JWT,签名固定为
|
||||
IssueUserToken(UserUUID, CompanyUUID, TenantUUID, RoleUUID) -> str。
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
user = _UserBytes(request)
|
||||
company = _CompanyBytes(request)
|
||||
tenant = _TenantBytes(request)
|
||||
if not user:
|
||||
return Response({'code': 1, 'msg': '无法识别当前用户'}, status=400)
|
||||
role = _RoleBytes(request)
|
||||
if role is None:
|
||||
role = TalkManager.GetUserRoleUUID(user, company)
|
||||
try:
|
||||
from gvsdsdk.talk_socket import IssueUserToken
|
||||
except ImportError:
|
||||
return Response({'code': 1, 'msg': 'Talk socket 服务未就绪'}, status=400)
|
||||
try:
|
||||
token = IssueUserToken(user, company, tenant, role)
|
||||
except Exception:
|
||||
return Response({'code': 1, 'msg': 'Talk 令牌签发失败'}, status=400)
|
||||
return Response({'code': 0, 'data': {
|
||||
'Token': token,
|
||||
'SocketPath': '/talk',
|
||||
'ExpiresIn': SOCKET_TOKEN_EXPIRES_IN,
|
||||
}})
|
||||
@@ -0,0 +1 @@
|
||||
"""枢机协律-支付系统(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)"""
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gerp_manifests.schema.json",
|
||||
"AppCode": "wallet",
|
||||
"AppName": "枢机协律-支付系统",
|
||||
"AppIcon": "Wallet",
|
||||
"AppDesc": "枢机协律-支付系统:个人钱包账户与支付",
|
||||
"AppType": "enterprise",
|
||||
"AppSort": 5,
|
||||
"FrontendRoute": "/private/CreateE/wallet",
|
||||
"FrontendType": "legacy",
|
||||
"LegacySrc": "pages/private/CreateE/wallet.html",
|
||||
"Menus": [
|
||||
{
|
||||
"MenuName": "create-e-wallet",
|
||||
"MenuTitle": "钱包",
|
||||
"MenuLink": "pages/private/CreateE/wallet.html",
|
||||
"MenuIcon": "Wallet",
|
||||
"MenuSort": 0,
|
||||
"ParentMenuName": "create-e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""GERP 应用的 AppConfig(原 apps.py)
|
||||
|
||||
为何文件名不是 apps.py:
|
||||
GERP 按子应用目录规约在包内放了 `apps/` 包(gvsds/apps/gerp/apps/<appenglishid>/),
|
||||
而 Django 的 AppConfig 自动发现会去找 `<app>.apps`(即 gvsds/apps/gerp/apps),
|
||||
与 `apps/` 包同名冲突——Python 会把 `gvsds.apps.gerp.apps` 解析为那个包目录,
|
||||
导致在 app 注册阶段(模型未就绪)就执行包内导入而报 AppRegistryNotReady。
|
||||
|
||||
解决办法:AppConfig 移到本模块,并在 INSTALLED_APPS 里用显式点分路径注册
|
||||
('gvsds.apps.gerp.gerp_app.GerpConfig'),Django 便不再自动探测 `<app>.apps`。
|
||||
label 仍为 'gerp',所有模型、迁移、导入路径保持不变。
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GerpConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gvsds.apps.gerp'
|
||||
label = 'gerp'
|
||||
verbose_name = 'GERP 企业管理'
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Talk 企业通讯 3 表——状态认领迁移(不建表)
|
||||
|
||||
背景:Talk 原为独立 app `gvsds.apps.talk`,其 `talk.0001_initial` 已在库中建好
|
||||
三张表(TalkConversation / TalkMember / TalkMessage)。按 GERP 子应用目录规约,
|
||||
Talk 已迁入 `gvsds/apps/gerp/apps/talk/`,模型 app_label 改为 'gerp',
|
||||
**db_table 与全部列名保持不变**。
|
||||
|
||||
因此本迁移使用 SeparateDatabaseAndState:
|
||||
- state_operations:把这三个模型的 state 登记进 gerp(内容等同原 talk.0001_initial)
|
||||
- database_operations:留空——表已存在,绝不能重复建表(重复建表会报 1050)
|
||||
|
||||
注意:本项目 `makemigrations` 全项目不可用(PascalCase 字段名与历史迁移字段名不一致),
|
||||
若后续用「空 ProjectState + 单 app 装载」的绕过办法生成迁移,本迁移保证 gerp 侧
|
||||
不会再把这三张表识别成"新表"而重复生成建表语句。
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import gvsds.apps.core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gerp', '0045_purge_asset_legacy_file_columns'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name='TalkConversation',
|
||||
fields=[
|
||||
('ConversationUUID', gvsds.apps.core.models.UUIDField(db_column='ConversationUUID', primary_key=True, serialize=False, verbose_name='会话UUID')),
|
||||
('CompanyUUID', gvsds.apps.core.models.UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID')),
|
||||
('TenantUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='TenantUUID', null=True, verbose_name='租户UUID')),
|
||||
('ConversationType', models.SmallIntegerField(db_column='ConversationType', default=0, verbose_name='会话类型(0单聊/1群聊)')),
|
||||
('Title', models.CharField(db_column='Title', default='', max_length=128, verbose_name='会话标题')),
|
||||
('CreatorUUID', gvsds.apps.core.models.UUIDField(db_column='CreatorUUID', verbose_name='创建人UUID')),
|
||||
('MemberCount', models.IntegerField(db_column='MemberCount', default=0, verbose_name='成员数')),
|
||||
('LastMessageUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='LastMessageUUID', null=True, verbose_name='最后消息UUID')),
|
||||
('LastMessageTime', models.DateTimeField(blank=True, db_column='LastMessageTime', null=True, verbose_name='最后消息时间')),
|
||||
('LastMessagePreview', models.CharField(db_column='LastMessagePreview', default='', max_length=256, verbose_name='最后消息摘要')),
|
||||
('Status', models.SmallIntegerField(db_column='Status', default=1, verbose_name='状态(1正常/0解散)')),
|
||||
('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'TalkConversation',
|
||||
'ordering': ['-LastMessageTime'],
|
||||
'verbose_name': 'Talk会话',
|
||||
'verbose_name_plural': 'Talk会话',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TalkMember',
|
||||
fields=[
|
||||
('MemberUUID', gvsds.apps.core.models.UUIDField(db_column='MemberUUID', primary_key=True, serialize=False, verbose_name='成员UUID')),
|
||||
('ConversationUUID', gvsds.apps.core.models.UUIDField(db_column='ConversationUUID', db_index=True, verbose_name='会话UUID')),
|
||||
('CompanyUUID', gvsds.apps.core.models.UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID')),
|
||||
('UserUUID', gvsds.apps.core.models.UUIDField(db_column='UserUUID', db_index=True, verbose_name='用户UUID')),
|
||||
('MemberRole', models.SmallIntegerField(db_column='MemberRole', default=0, verbose_name='成员角色(0成员/1群主)')),
|
||||
('JoinTime', models.DateTimeField(db_column='JoinTime', default=datetime.datetime.utcnow, verbose_name='加入时间')),
|
||||
('LastReadTime', models.DateTimeField(blank=True, db_column='LastReadTime', null=True, verbose_name='最后已读时间')),
|
||||
('LastReadMessageUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='LastReadMessageUUID', null=True, verbose_name='最后已读消息UUID')),
|
||||
('MuteFlag', models.SmallIntegerField(db_column='MuteFlag', default=0, verbose_name='免打扰(0/1)')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'TalkMember',
|
||||
'unique_together': (('ConversationUUID', 'UserUUID'),),
|
||||
'verbose_name': 'Talk会话成员',
|
||||
'verbose_name_plural': 'Talk会话成员',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TalkMessage',
|
||||
fields=[
|
||||
('MessageUUID', gvsds.apps.core.models.UUIDField(db_column='MessageUUID', primary_key=True, serialize=False, verbose_name='消息UUID')),
|
||||
('ConversationUUID', gvsds.apps.core.models.UUIDField(db_column='ConversationUUID', db_index=True, verbose_name='会话UUID')),
|
||||
('CompanyUUID', gvsds.apps.core.models.UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID')),
|
||||
('SenderUUID', gvsds.apps.core.models.UUIDField(db_column='SenderUUID', db_index=True, verbose_name='发送人UUID')),
|
||||
('MsgType', models.SmallIntegerField(db_column='MsgType', default=0, verbose_name='消息类型(0文本/1图片/2文件/3系统)')),
|
||||
('Content', models.TextField(db_column='Content', default='', verbose_name='消息内容')),
|
||||
('AttachmentSHA1', models.CharField(db_column='AttachmentSHA1', default='', max_length=40, verbose_name='附件SHA1')),
|
||||
('AttachmentName', models.CharField(db_column='AttachmentName', default='', max_length=256, verbose_name='附件文件名')),
|
||||
('AttachmentSize', models.BigIntegerField(db_column='AttachmentSize', default=0, verbose_name='附件大小')),
|
||||
('AttachmentMime', models.CharField(db_column='AttachmentMime', default='', max_length=128, verbose_name='附件MIME')),
|
||||
('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'TalkMessage',
|
||||
'ordering': ['CreateTime'],
|
||||
'verbose_name': 'Talk消息',
|
||||
'verbose_name_plural': 'Talk消息',
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
+14
-468
@@ -557,394 +557,6 @@ class Liability(QModel):
|
||||
verbose_name_plural = '负债'
|
||||
|
||||
|
||||
class FinanceVoucher(QModel):
|
||||
"""凭证主表——鼎算财务:记账凭证头(含借贷合计冗余)"""
|
||||
|
||||
VoucherUUID = UUIDField(primary_key=True, verbose_name='凭证UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
VoucherCode = models.CharField(max_length=32, default='', verbose_name='凭证字号')
|
||||
VoucherDate = models.DateField(null=True, blank=True, verbose_name='凭证日期')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
Summary = models.CharField(max_length=256, default='', verbose_name='摘要')
|
||||
TotalDebit = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方合计')
|
||||
TotalCredit = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方合计')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=草稿 1=已过账 2=已作废
|
||||
CreatorUUID = UUIDField(null=True, blank=True, verbose_name='制单人UUID')
|
||||
AuditorUUID = UUIDField(null=True, blank=True, verbose_name='审核人UUID')
|
||||
AuditTime = models.DateTimeField(null=True, blank=True, verbose_name='审核时间')
|
||||
AttachmentCount = models.IntegerField(default=0, verbose_name='附件数')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceVoucher'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-VoucherDate', '-CreateTime']
|
||||
verbose_name = '凭证'
|
||||
verbose_name_plural = '凭证'
|
||||
|
||||
def __str__(self):
|
||||
return self.VoucherCode or str(self.pk)
|
||||
|
||||
|
||||
class FinanceVoucherEntry(QModel):
|
||||
"""凭证分录表——鼎算财务:一张凭证的多行借贷分录"""
|
||||
|
||||
VoucherEntryUUID = UUIDField(primary_key=True, verbose_name='分录UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
VoucherUUID = UUIDField(db_column='VoucherUUID', verbose_name='所属凭证UUID')
|
||||
AccountCode = models.CharField(max_length=32, default='', verbose_name='科目编码')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='科目名称')
|
||||
EntrySummary = models.CharField(max_length=256, default='', verbose_name='分录摘要')
|
||||
DebitAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方金额')
|
||||
CreditAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方金额')
|
||||
SortOrder = models.IntegerField(default=0, verbose_name='排序')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceVoucherEntry'
|
||||
app_label = 'gerp'
|
||||
ordering = ['SortOrder', 'CreateTime']
|
||||
verbose_name = '凭证分录'
|
||||
verbose_name_plural = '凭证分录'
|
||||
|
||||
|
||||
class FinanceCashier(QModel):
|
||||
"""出纳流水表——鼎算财务:现金/银行存款收支记录"""
|
||||
|
||||
CashierUUID = UUIDField(primary_key=True, verbose_name='流水UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
TradeDate = models.DateField(null=True, blank=True, verbose_name='日期')
|
||||
TradeType = models.SmallIntegerField(default=1, verbose_name='类型') # 1=收入 2=支出 3=转账
|
||||
BankAccount = models.CharField(max_length=64, default='', verbose_name='银行账户')
|
||||
Counterparty = models.CharField(max_length=128, default='', verbose_name='对方单位/个人')
|
||||
Amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='结余')
|
||||
OperatorUUID = UUIDField(null=True, blank=True, verbose_name='经办人UUID')
|
||||
RelatedVoucherUUID = UUIDField(null=True, blank=True, db_column='RelatedVoucherUUID', verbose_name='关联凭证UUID')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceCashier'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-TradeDate', '-CreateTime']
|
||||
verbose_name = '出纳流水'
|
||||
verbose_name_plural = '出纳流水'
|
||||
|
||||
|
||||
class FinanceLedger(QModel):
|
||||
"""会计账簿表——鼎算财务:科目发生额与余额记录"""
|
||||
|
||||
LedgerUUID = UUIDField(primary_key=True, verbose_name='账簿UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
EntryDate = models.DateField(null=True, blank=True, verbose_name='记账日期')
|
||||
AccountCode = models.CharField(max_length=32, default='', verbose_name='科目编码')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='科目名称')
|
||||
Summary = models.CharField(max_length=256, default='', verbose_name='摘要')
|
||||
DebitAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='借方金额')
|
||||
CreditAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='贷方金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='余额')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceLedger'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-EntryDate', '-CreateTime']
|
||||
verbose_name = '会计账簿'
|
||||
verbose_name_plural = '会计账簿'
|
||||
|
||||
|
||||
class FinanceReport(QModel):
|
||||
"""财务报表快照表——鼎算财务:利润表/资产负债表项目行"""
|
||||
|
||||
ReportUUID = UUIDField(primary_key=True, verbose_name='报表UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReportType = models.SmallIntegerField(default=1, verbose_name='报表类型') # 1=利润表 2=资产负债表
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
ItemCode = models.CharField(max_length=32, default='', verbose_name='项目编码')
|
||||
ItemName = models.CharField(max_length=64, default='', verbose_name='项目名称')
|
||||
CurrentAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='本期金额')
|
||||
CumulativeAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='累计金额')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceReport'
|
||||
app_label = 'gerp'
|
||||
ordering = ['Period', 'ReportType', 'ItemCode']
|
||||
verbose_name = '财务报表'
|
||||
verbose_name_plural = '财务报表'
|
||||
|
||||
|
||||
class FinanceReimburse(QModel):
|
||||
"""费用报销表——鼎算财务:报销单与审批流"""
|
||||
|
||||
ReimburseUUID = UUIDField(primary_key=True, verbose_name='报销单UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReimburseCode = models.CharField(max_length=32, default='', verbose_name='报销单号')
|
||||
ApplicantUUID = UUIDField(null=True, blank=True, verbose_name='报销人UUID')
|
||||
DepartmentName = models.CharField(max_length=64, default='', verbose_name='部门')
|
||||
ExpenseType = models.CharField(max_length=32, default='', verbose_name='费用类型')
|
||||
Amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='金额')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='事由')
|
||||
InvoiceCount = models.IntegerField(default=0, verbose_name='发票数')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=待审批 1=已通过 2=已驳回 3=已打款
|
||||
AuditorUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
AuditTime = models.DateTimeField(null=True, blank=True, verbose_name='审批时间')
|
||||
AuditRemark = models.CharField(max_length=256, default='', verbose_name='审批意见')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceReimburse'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '费用报销'
|
||||
verbose_name_plural = '费用报销'
|
||||
|
||||
|
||||
class FinanceArap(QModel):
|
||||
"""应收应付表——鼎算财务:往来账款与结算"""
|
||||
|
||||
ArapUUID = UUIDField(primary_key=True, verbose_name='往来UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ArapType = models.SmallIntegerField(default=1, verbose_name='类型') # 1=应收 2=应付
|
||||
CounterpartyUUID = UUIDField(null=True, blank=True, db_column='CounterpartyUUID', verbose_name='往来对象UUID')
|
||||
CounterpartyName = models.CharField(max_length=128, default='', verbose_name='往来对象名称')
|
||||
TotalAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='总额')
|
||||
PaidAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='已收付金额')
|
||||
Balance = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='余额')
|
||||
DueDate = models.DateField(null=True, blank=True, verbose_name='到期日')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=未结清 1=部分结清 2=已结清
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceArap'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '应收应付'
|
||||
verbose_name_plural = '应收应付'
|
||||
|
||||
|
||||
class FinanceBudget(QModel):
|
||||
"""预算管理表——鼎算财务:期间×部门/科目的预算与执行"""
|
||||
|
||||
BudgetUUID = UUIDField(primary_key=True, verbose_name='预算UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='会计期间')
|
||||
DeptUUID = UUIDField(null=True, blank=True, db_column='DeptUUID', verbose_name='部门UUID')
|
||||
DeptName = models.CharField(max_length=64, default='', verbose_name='部门名称')
|
||||
AccountName = models.CharField(max_length=64, default='', verbose_name='预算科目')
|
||||
BudgetAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='预算金额')
|
||||
ExecutedAmount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='已执行金额')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'FinanceBudget'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '预算管理'
|
||||
verbose_name_plural = '预算管理'
|
||||
|
||||
|
||||
class EmployeeProfile(QModel):
|
||||
"""员工档案表——eHRM:员工基本信息与任职档案"""
|
||||
|
||||
EmployeeProfileUUID = UUIDField(primary_key=True, verbose_name='档案UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
DeptUUID = UUIDField(null=True, blank=True, db_column='DeptUUID', verbose_name='部门UUID')
|
||||
DeptName = models.CharField(max_length=64, default='', verbose_name='部门名称')
|
||||
EmployeeNo = models.CharField(max_length=32, default='', verbose_name='工号')
|
||||
HireDate = models.DateField(null=True, blank=True, verbose_name='入职日期')
|
||||
PositionName = models.CharField(max_length=64, default='', verbose_name='职务')
|
||||
Education = models.CharField(max_length=32, default='', verbose_name='学历')
|
||||
EmergencyContact = models.CharField(max_length=32, default='', verbose_name='紧急联系人')
|
||||
EmergencyPhone = models.CharField(max_length=32, default='', verbose_name='紧急联系电话')
|
||||
Address = models.CharField(max_length=256, default='', verbose_name='联系地址')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeeProfile'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工档案'
|
||||
verbose_name_plural = '员工档案'
|
||||
|
||||
|
||||
class EmployeeAssessment(QModel):
|
||||
"""员工考核表——eHRM:考核期间、评分与等级结论"""
|
||||
|
||||
AssessmentUUID = UUIDField(primary_key=True, verbose_name='考核UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='考核期间')
|
||||
AssessorUUID = UUIDField(null=True, blank=True, verbose_name='考核人UUID')
|
||||
Score = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='评分')
|
||||
Grade = models.CharField(max_length=16, default='', verbose_name='等级')
|
||||
Result = models.CharField(max_length=512, default='', verbose_name='考核结论')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0=草稿 1=已确认
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeeAssessment'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工考核'
|
||||
verbose_name_plural = '员工考核'
|
||||
|
||||
|
||||
class EmployeePerformance(QModel):
|
||||
"""员工绩效表——eHRM:绩效指标的目标/实际值与得分"""
|
||||
|
||||
PerformanceUUID = UUIDField(primary_key=True, verbose_name='绩效UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='员工用户UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='绩效期间')
|
||||
MetricName = models.CharField(max_length=64, default='', verbose_name='绩效指标')
|
||||
TargetValue = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='目标值')
|
||||
ActualValue = models.DecimalField(max_digits=14, decimal_places=2, default=0.00, verbose_name='实际值')
|
||||
Score = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='得分')
|
||||
Weight = models.DecimalField(max_digits=6, decimal_places=2, default=0.00, verbose_name='权重(%)')
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'EmployeePerformance'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '员工绩效'
|
||||
verbose_name_plural = '员工绩效'
|
||||
|
||||
|
||||
class OaLeave(QModel):
|
||||
"""请假申请表——OA:请假类型、起止时间与审批状态"""
|
||||
|
||||
OaLeaveUUID = UUIDField(primary_key=True, verbose_name='请假UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='申请人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
LeaveType = models.SmallIntegerField(default=1, verbose_name='请假类型') # 1年假/2事假/3病假/4调休/5其他
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
Days = models.DecimalField(max_digits=5, decimal_places=1, default=0.0, verbose_name='请假天数')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='请假事由')
|
||||
ApproverUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1待审批/2已通过/3已驳回
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaLeave'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '请假申请'
|
||||
verbose_name_plural = '请假申请'
|
||||
|
||||
|
||||
class OaTravel(QModel):
|
||||
"""出差申请表——OA:目的地、起止时间与审批状态"""
|
||||
|
||||
OaTravelUUID = UUIDField(primary_key=True, verbose_name='出差UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='申请人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Destination = models.CharField(max_length=128, default='', verbose_name='目的地')
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
Days = models.DecimalField(max_digits=5, decimal_places=1, default=0.0, verbose_name='出差天数')
|
||||
Reason = models.CharField(max_length=512, default='', verbose_name='出差事由')
|
||||
ApproverUUID = UUIDField(null=True, blank=True, verbose_name='审批人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1待审批/2已通过/3已驳回
|
||||
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaTravel'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '出差申请'
|
||||
verbose_name_plural = '出差申请'
|
||||
|
||||
|
||||
class OaMeeting(QModel):
|
||||
"""会议管理表——OA:会议安排、主持人与参会人"""
|
||||
|
||||
OaMeetingUUID = UUIDField(primary_key=True, verbose_name='会议UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Title = models.CharField(max_length=128, default='', verbose_name='会议主题')
|
||||
Location = models.CharField(max_length=128, default='', verbose_name='会议地点')
|
||||
StartTime = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
EndTime = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
HostUUID = UUIDField(null=True, blank=True, verbose_name='主持人UUID')
|
||||
Attendees = models.CharField(max_length=512, default='', verbose_name='参会人')
|
||||
Summary = models.CharField(max_length=1024, default='', verbose_name='会议纪要')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaMeeting'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '会议管理'
|
||||
verbose_name_plural = '会议管理'
|
||||
|
||||
|
||||
class OaAnnouncement(QModel):
|
||||
"""公告通知表——OA:公司公告、通知发布"""
|
||||
|
||||
OaAnnouncementUUID = UUIDField(primary_key=True, verbose_name='公告UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
Title = models.CharField(max_length=128, default='', verbose_name='公告标题')
|
||||
Content = models.TextField(default='', verbose_name='公告内容')
|
||||
PublisherUUID = UUIDField(null=True, blank=True, verbose_name='发布人UUID')
|
||||
Status = models.SmallIntegerField(default=0, verbose_name='状态') # 0草稿/1已发布
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaAnnouncement'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '公告通知'
|
||||
verbose_name_plural = '公告通知'
|
||||
|
||||
|
||||
class OaReport(QModel):
|
||||
"""工作汇报表——OA:日报/周报/月报"""
|
||||
|
||||
OaReportUUID = UUIDField(primary_key=True, verbose_name='汇报UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='汇报人UUID')
|
||||
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
|
||||
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
ReportType = models.SmallIntegerField(default=1, verbose_name='汇报类型') # 1日报/2周报/3月报
|
||||
Period = models.CharField(max_length=16, default='', verbose_name='汇报期间')
|
||||
Content = models.TextField(default='', verbose_name='汇报内容')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'OaReport'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '工作汇报'
|
||||
verbose_name_plural = '工作汇报'
|
||||
|
||||
|
||||
class UserIDCard(QModel):
|
||||
"""用户身份证表——实名信息"""
|
||||
|
||||
@@ -1093,86 +705,6 @@ class AbnormalUserLog(QModel):
|
||||
return f'{self.AttemptType}:{self.IPAddress} @ {self.CreateTime}'
|
||||
|
||||
|
||||
class UserMailbox(QModel):
|
||||
"""用户邮箱表——记录员工与 Stalwart 邮箱账户的映射
|
||||
|
||||
申请邮箱时在 Stalwart 创建 individual principal(xxx@gvsds.com),
|
||||
本表记录归属关系与配额;密码不落库(员工自设,仅存于 Stalwart)。
|
||||
"""
|
||||
|
||||
UserMailboxUUID = UUIDField(primary_key=True, verbose_name='邮箱记录UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='用户UUID')
|
||||
MailboxName = models.CharField(max_length=256, unique=True, verbose_name='邮箱名称')
|
||||
PrincipalId = models.IntegerField(null=True, blank=True, verbose_name='Stalwart账户ID')
|
||||
QuotaMB = models.IntegerField(default=1024, verbose_name='配额(MB)')
|
||||
Status = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'UserMailbox'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '用户邮箱'
|
||||
verbose_name_plural = '用户邮箱'
|
||||
|
||||
def __str__(self):
|
||||
return self.MailboxName
|
||||
|
||||
|
||||
class MailContact(QModel):
|
||||
"""邮箱联系人表——发件时自动存储常用联系人(云端 SQL 存储)
|
||||
|
||||
发信成功后自动把收件人存入本表(按 用户+邮箱 去重),
|
||||
写信时前端按关键字匹配下拉选择;联系人名取自联系人显示名(可后续编辑)。
|
||||
"""
|
||||
|
||||
MailContactUUID = UUIDField(primary_key=True, default=_NewUuidBytes, verbose_name='联系人UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='归属用户UUID')
|
||||
ContactName = models.CharField(max_length=128, default='', blank=True, verbose_name='联系人名称')
|
||||
ContactEmail = models.CharField(max_length=256, db_index=True, verbose_name='联系人邮箱')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'MailContact'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-UpdateTime']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['UserUUID', 'ContactEmail'], name='uniq_user_contact_email'),
|
||||
]
|
||||
verbose_name = '邮箱联系人'
|
||||
verbose_name_plural = '邮箱联系人'
|
||||
|
||||
def __str__(self):
|
||||
return self.ContactEmail
|
||||
|
||||
|
||||
class UserGiteaAccount(QModel):
|
||||
"""用户 Gitea 账号表——记录员工与 Gitea 账号的映射
|
||||
|
||||
申请 Gitea 账号时在 git.gvsds.com 创建用户并加入组织(GVSDS),
|
||||
本表记录归属关系;密码不落库(员工自设,仅存于 Gitea)。
|
||||
"""
|
||||
|
||||
UserGiteaAccountUUID = UUIDField(primary_key=True, verbose_name='Gitea记录UUID')
|
||||
UserUUID = UUIDField(db_column='UserUUID', db_index=True, verbose_name='用户UUID')
|
||||
GiteaLogin = models.CharField(max_length=256, unique=True, verbose_name='Gitea用户名')
|
||||
GiteaUserId = models.IntegerField(null=True, blank=True, verbose_name='Gitea用户ID')
|
||||
Email = models.CharField(max_length=256, default='', blank=True, db_column='Email', verbose_name='工作邮箱')
|
||||
Status = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'UserGiteaAccount'
|
||||
app_label = 'gerp'
|
||||
ordering = ['-CreateTime']
|
||||
verbose_name = '用户Gitea账号'
|
||||
verbose_name_plural = '用户Gitea账号'
|
||||
|
||||
def __str__(self):
|
||||
return self.GiteaLogin
|
||||
|
||||
|
||||
class App(QModel):
|
||||
"""应用/功能注册表——企业功能与个人应用的统一实体
|
||||
|
||||
@@ -1364,3 +896,17 @@ from gvsdsdk.models import (
|
||||
Position, PositionCategory, UserRoleDepartmentPosition,
|
||||
AppVirtualPosition, AppVirtualBinding,
|
||||
)
|
||||
|
||||
|
||||
# ==================== GERP 子应用模型(新目录规约) ====================
|
||||
# 子应用模型定义在 gvsds/apps/gerp/apps/<appenglishid>/models.py(app_label 仍为 'gerp',
|
||||
# 迁移仍集中在 gerp/migrations/)。此处在模型加载阶段统一导入并把公开名字注入本模块命名空间:
|
||||
# 1) 保证 Django 加载 gerp.models 时即完成子应用模型注册
|
||||
# 2) 保持历史导入路径 `from gvsds.apps.gerp.models import X` 继续可用
|
||||
#
|
||||
# 约定:子应用模型若要引用本模块的平台模型,必须用绝对导入
|
||||
# `from gvsds.apps.gerp.models import X`;子应用包不得在 import 时导入模型
|
||||
# (否则会在 app 注册阶段触发 AppRegistryNotReady)。
|
||||
from .apps import ImportSubAppModels as _ImportSubAppModels # noqa: E402
|
||||
|
||||
_ImportSubAppModels(namespace=globals())
|
||||
@@ -224,7 +224,7 @@ def _VerifyGerpSession(request):
|
||||
|
||||
def _GetGiteaIdentity(user):
|
||||
"""按 GERP 用户查 Gitea 绑定(GiteaLogin + Email),返回 dict 或 None。"""
|
||||
from .gitea_manager import GiteaManager
|
||||
from gvsds.apps.gerp.apps.gitea.manager import GiteaManager
|
||||
rec = GiteaManager.GetActiveAccount(user.UserUUID)
|
||||
if rec is None:
|
||||
return None
|
||||
|
||||
@@ -124,7 +124,7 @@ from .payment_config import (
|
||||
PaymentConfigDeleteView,
|
||||
)
|
||||
from .dashboard import DashboardStatsView
|
||||
from .mail import (
|
||||
from gvsds.apps.gerp.apps.mailbox.views import (
|
||||
MailSessionView,
|
||||
MailApplyView,
|
||||
MailChangePasswordView,
|
||||
@@ -136,7 +136,7 @@ from .mail import (
|
||||
MailAdminListView,
|
||||
MailDisableView,
|
||||
)
|
||||
from .gitea import (
|
||||
from gvsds.apps.gerp.apps.gitea.views import (
|
||||
GiteaSessionView,
|
||||
GiteaApplyView,
|
||||
GiteaMyAccountView,
|
||||
@@ -174,7 +174,7 @@ from .system_ops import (
|
||||
GERPSystemSecretsView,
|
||||
GERPSystemRuntimeView,
|
||||
)
|
||||
from .oa import (
|
||||
from gvsds.apps.gerp.apps.oa.views import (
|
||||
OaPendingApprovalsView,
|
||||
OaApproveView,
|
||||
)
|
||||
+1
-1
@@ -52,7 +52,7 @@ INSTALLED_APPS = _app_getlist('apps', 'INSTALLED_APPS', fallback=[
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
'gvsds.apps.core',
|
||||
'gvsds.apps.gerp',
|
||||
'gvsds.apps.gerp.gerp_app.GerpConfig',
|
||||
'gvsds.apps.ugm',
|
||||
'gvsds.apps.crb',
|
||||
'gvsds.apps.invite',
|
||||
|
||||
+10
-21
@@ -164,8 +164,6 @@ urlpatterns = [
|
||||
path('API/App/Pending', gerp_views.AppPendingView.as_view(), name='app_pending'),
|
||||
path('API/App/SetStrictVisible', gerp_views.AppSetStrictVisibleView.as_view(), name='app_set_strict_visible'),
|
||||
path('API/App/VirtualMapping', gerp_views.AppVirtualMappingView.as_view(), name='app_virtual_mapping'),
|
||||
path('API/Oa/PendingApprovals', gerp_views.OaPendingApprovalsView.as_view(), name='oa_pending_approvals'),
|
||||
path('API/Oa/Approve', gerp_views.OaApproveView.as_view(), name='oa_approve'),
|
||||
path('API/App/VirtualAutoBind', gerp_views.AppVirtualAutoBindView.as_view(), name='app_virtual_auto_bind'),
|
||||
path('API/App/PositionTemplateDiff', gerp_views.AppPositionTemplateDiffView.as_view(), name='app_position_template_diff'),
|
||||
path('API/App/PositionTemplateSync', gerp_views.AppPositionTemplateSyncView.as_view(), name='app_position_template_sync'),
|
||||
@@ -194,26 +192,14 @@ urlpatterns = [
|
||||
# 仪表盘统计(按角色差异化)
|
||||
path('API/Dashboard/Stats', gerp_views.DashboardStatsView.as_view(), name='dashboard_stats'),
|
||||
|
||||
# 员工邮箱(Stalwart 邮件服务器)
|
||||
path('API/Mail/Session', gerp_views.MailSessionView.as_view(), name='mail_session'),
|
||||
path('API/Mail/Apply', gerp_views.MailApplyView.as_view(), name='mail_apply'),
|
||||
path('API/Mail/ChangePassword', gerp_views.MailChangePasswordView.as_view(), name='mail_change_password'),
|
||||
path('API/Mail/MyMailbox', gerp_views.MailMyMailboxView.as_view(), name='mail_my_mailbox'),
|
||||
path('API/Mail/Login', gerp_views.MailLoginView.as_view(), name='mail_login'),
|
||||
path('API/Mail/Logout', gerp_views.MailLogoutView.as_view(), name='mail_logout'),
|
||||
path('API/Mail/Jmap', gerp_views.MailJmapView.as_view(), name='mail_jmap'),
|
||||
path('API/Mail/Contacts', gerp_views.MailContactsView.as_view(), name='mail_contacts'),
|
||||
path('API/Mail/AdminList', gerp_views.MailAdminListView.as_view(), name='mail_admin_list'),
|
||||
path('API/Mail/Disable', gerp_views.MailDisableView.as_view(), name='mail_disable'),
|
||||
# 员工邮箱(Stalwart 邮件服务器)——路由在子应用内(apps/mailbox/urls.py)
|
||||
path('API/Mail/', include('gvsds.apps.gerp.apps.mailbox.urls')),
|
||||
|
||||
# Gitea 代码托管对接(员工申请 + 管理员管理)
|
||||
path('API/Gitea/Session', gerp_views.GiteaSessionView.as_view(), name='gitea_session'),
|
||||
path('API/Gitea/Apply', gerp_views.GiteaApplyView.as_view(), name='gitea_apply'),
|
||||
path('API/Gitea/MyAccount', gerp_views.GiteaMyAccountView.as_view(), name='gitea_my_account'),
|
||||
path('API/Gitea/ChangePassword', gerp_views.GiteaChangePasswordView.as_view(), name='gitea_change_password'),
|
||||
path('API/Gitea/Avatar', gerp_views.GiteaAvatarView.as_view(), name='gitea_avatar'),
|
||||
path('API/Gitea/AdminList', gerp_views.GiteaAdminListView.as_view(), name='gitea_admin_list'),
|
||||
path('API/Gitea/Disable', gerp_views.GiteaDisableView.as_view(), name='gitea_disable'),
|
||||
# Gitea 代码托管对接(员工申请 + 管理员管理)——路由在子应用内(apps/gitea/urls.py)
|
||||
path('API/Gitea/', include('gvsds.apps.gerp.apps.gitea.urls')),
|
||||
|
||||
# 办公自动化流程(OA 审批)——路由在子应用内(apps/oa/urls.py)
|
||||
path('API/Oa/', include('gvsds.apps.gerp.apps.oa.urls')),
|
||||
|
||||
# GERP系统 运维管理(仅系统管理员 admin:super)
|
||||
path('API/GERPSystem/Status', gerp_views.GERPSystemStatusView.as_view(), name='gerp_system_status'),
|
||||
@@ -295,6 +281,9 @@ urlpatterns = [
|
||||
|
||||
# RollCall 课堂点名——数据持久化(学生/题目/课堂记录整体读写)
|
||||
path('API/RollCall/', include('gvsds.apps.rollcall.urls')),
|
||||
|
||||
# Talk 企业通讯(单聊/群聊、图片与文件消息)
|
||||
path('API/Talk/', include('gvsds.apps.gerp.apps.talk.urls')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
|
||||
Reference in new issue
Block a user