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:
TermiNexus committed 2026-09-26 23:46:00 +08:00
1 parent 00b8e32b22
commit fbf1bb7d6e
54 files changed
+3129 -910

No files matched your search

+65 -8
View File
@@ -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
View File
@@ -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 远端清单注册中心(预留) ====================
-8
View File
@@ -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 企业管理'
+63
View File
@@ -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 = '预算管理'
+1
View File
@@ -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": ["人事专员", "考核专员", "绩效专员", "人事主管", "人力资源经理"]
}
]
}
+83
View File
@@ -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" }
}
}
}
}
}
}
+1
View File
@@ -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.
+34
View File
@@ -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
+19
View File
@@ -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__)
+1
View File
@@ -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.
+63
View File
@@ -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
+22
View File
@@ -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__)
+1
View File
@@ -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.
+121
View File
@@ -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 = '工作汇报'
+16
View File
@@ -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": ["产品专员", "订单专员", "业务主管", "业务经理"]
}
]
}
+1
View File
@@ -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"
}
]
}
+5
View File
@@ -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" }
]
}
+863
View File
@@ -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,
}
+97
View File
@@ -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}]'
+21
View File
@@ -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)
+19
View File
@@ -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()),
]
+391
View File
@@ -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,
}})
+1
View File
@@ -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"
}
]
}
+21
View File
@@ -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
View File
@@ -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())
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 += [