diff --git a/gvsds/apps/api/views.py b/gvsds/apps/api/views.py index fd768479..a8582bf3 100644 --- a/gvsds/apps/api/views.py +++ b/gvsds/apps/api/views.py @@ -20,21 +20,78 @@ class IPInfoView(APIView): class AssetDownloadView(View): + """文件资产下载(按 SHA1) + + 取物理文件的顺序(先新后旧): + 1. FileAsset.StoragePath —— 当前上传链路(/API/Asset/Upload*)的落地位置 + (MEDIA_ROOT/assets/_<原始文件名>) + 2. ./resource//// —— 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//// + 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): diff --git a/gvsds/apps/gerp/app_manifests.py b/gvsds/apps/gerp/app_manifests.py index 1184e50f..f0c85d29 100644 --- a/gvsds/apps/gerp/app_manifests.py +++ b/gvsds/apps/gerp/app_manifests.py @@ -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//)的模型由 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// 下 +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//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 远端清单注册中心(预留) ==================== diff --git a/gvsds/apps/gerp/apps.py b/gvsds/apps/gerp/apps.py deleted file mode 100644 index 75247e97..00000000 --- a/gvsds/apps/gerp/apps.py +++ /dev/null @@ -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 企业管理' diff --git a/gvsds/apps/gerp/apps/__init__.py b/gvsds/apps/gerp/apps/__init__.py new file mode 100644 index 00000000..72423867 --- /dev/null +++ b/gvsds/apps/gerp/apps/__init__.py @@ -0,0 +1,63 @@ +"""GERP 子应用包(统一目录规约) + +规约:一个 GERP 应用 = 本包下的一个子目录,目录名即应用英文 ID +(如 `mailbox/`、`gitea/`、`oa/`、`talk/`),目录内固定结构: + + apps// + __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 约定的 `.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) diff --git a/gvsds/apps/gerp/apps/dingcalc_finance/__init__.py b/gvsds/apps/gerp/apps/dingcalc_finance/__init__.py new file mode 100644 index 00000000..24ece692 --- /dev/null +++ b/gvsds/apps/gerp/apps/dingcalc_finance/__init__.py @@ -0,0 +1 @@ +"""鼎算财务(GERP 子应用)""" diff --git a/gvsds/apps/gerp/apps/dingcalc_finance/apps.gerp_manifests b/gvsds/apps/gerp/apps/dingcalc_finance/apps.gerp_manifests new file mode 100644 index 00000000..deb16392 --- /dev/null +++ b/gvsds/apps/gerp/apps/dingcalc_finance/apps.gerp_manifests @@ -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": ["财务专员", "出纳", "会计", "财务主管", "财务经理", "财务总监"] + } + ] +} diff --git a/gvsds/apps/gerp/apps/dingcalc_finance/models.py b/gvsds/apps/gerp/apps/dingcalc_finance/models.py new file mode 100644 index 00000000..4cbaec8c --- /dev/null +++ b/gvsds/apps/gerp/apps/dingcalc_finance/models.py @@ -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 = '预算管理' diff --git a/gvsds/apps/gerp/apps/ehrm/__init__.py b/gvsds/apps/gerp/apps/ehrm/__init__.py new file mode 100644 index 00000000..43e98f74 --- /dev/null +++ b/gvsds/apps/gerp/apps/ehrm/__init__.py @@ -0,0 +1 @@ +"""eHRM 人力资源扩展(GERP 子应用)——按 GERP 子应用目录规约组织,迁移仍集中在 gerp/migrations/。""" diff --git a/gvsds/apps/gerp/apps/ehrm/apps.gerp_manifests b/gvsds/apps/gerp/apps/ehrm/apps.gerp_manifests new file mode 100644 index 00000000..e2c1c849 --- /dev/null +++ b/gvsds/apps/gerp/apps/ehrm/apps.gerp_manifests @@ -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": ["人事专员", "考核专员", "绩效专员", "人事主管", "人力资源经理"] + } + ] +} diff --git a/gvsds/apps/gerp/apps/ehrm/models.py b/gvsds/apps/gerp/apps/ehrm/models.py new file mode 100644 index 00000000..479aceff --- /dev/null +++ b/gvsds/apps/gerp/apps/ehrm/models.py @@ -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 = '员工绩效' diff --git a/gvsds/apps/gerp/apps/enterprise/__init__.py b/gvsds/apps/gerp/apps/enterprise/__init__.py new file mode 100644 index 00000000..42c325cf --- /dev/null +++ b/gvsds/apps/gerp/apps/enterprise/__init__.py @@ -0,0 +1 @@ +"""枢机协律-模拟企业(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)""" diff --git a/gvsds/apps/gerp/apps/enterprise/apps.gerp_manifests b/gvsds/apps/gerp/apps/enterprise/apps.gerp_manifests new file mode 100644 index 00000000..d718a205 --- /dev/null +++ b/gvsds/apps/gerp/apps/enterprise/apps.gerp_manifests @@ -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" + } + ] +} diff --git a/gvsds/apps/gerp/apps/gerp_manifests.schema.json b/gvsds/apps/gerp/apps/gerp_manifests.schema.json new file mode 100644 index 00000000..9834eac7 --- /dev/null +++ b/gvsds/apps/gerp/apps/gerp_manifests.schema.json @@ -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//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" } + } + } + } + } + } +} diff --git a/gvsds/apps/gerp/apps/gitea/__init__.py b/gvsds/apps/gerp/apps/gitea/__init__.py new file mode 100644 index 00000000..dde65cce --- /dev/null +++ b/gvsds/apps/gerp/apps/gitea/__init__.py @@ -0,0 +1 @@ +"""Gitea 代码托管(GERP 子应用)——Gitea 账号申请、组织团队加入、改密、头像迁移与管理员禁用。""" diff --git a/gvsds/apps/gerp/apps/gitea/apps.gerp_manifests b/gvsds/apps/gerp/apps/gitea/apps.gerp_manifests new file mode 100644 index 00000000..9a80f32e --- /dev/null +++ b/gvsds/apps/gerp/apps/gitea/apps.gerp_manifests @@ -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,多个用逗号分隔" } + ] +} diff --git a/gvsds/apps/gerp/gitea.py b/gvsds/apps/gerp/apps/gitea/gitea.py similarity index 100% rename from gvsds/apps/gerp/gitea.py rename to gvsds/apps/gerp/apps/gitea/gitea.py diff --git a/gvsds/apps/gerp/gitea_manager.py b/gvsds/apps/gerp/apps/gitea/manager.py similarity index 100% rename from gvsds/apps/gerp/gitea_manager.py rename to gvsds/apps/gerp/apps/gitea/manager.py diff --git a/gvsds/apps/gerp/apps/gitea/models.py b/gvsds/apps/gerp/apps/gitea/models.py new file mode 100644 index 00000000..24e7bd70 --- /dev/null +++ b/gvsds/apps/gerp/apps/gitea/models.py @@ -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 diff --git a/gvsds/apps/gerp/apps/gitea/urls.py b/gvsds/apps/gerp/apps/gitea/urls.py new file mode 100644 index 00000000..56fa3198 --- /dev/null +++ b/gvsds/apps/gerp/apps/gitea/urls.py @@ -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'), +] diff --git a/gvsds/apps/gerp/views/gitea.py b/gvsds/apps/gerp/apps/gitea/views.py similarity index 98% rename from gvsds/apps/gerp/views/gitea.py rename to gvsds/apps/gerp/apps/gitea/views.py index 7cabe01e..cbe2a08f 100644 --- a/gvsds/apps/gerp/views/gitea.py +++ b/gvsds/apps/gerp/apps/gitea/views.py @@ -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__) diff --git a/gvsds/apps/gerp/apps/mailbox/__init__.py b/gvsds/apps/gerp/apps/mailbox/__init__.py new file mode 100644 index 00000000..ab4f56d6 --- /dev/null +++ b/gvsds/apps/gerp/apps/mailbox/__init__.py @@ -0,0 +1 @@ +"""Mailbox 企业邮箱(GERP 子应用)——Stalwart 邮箱申请、修改密码、JMAP 收发代理与联系人管理。""" diff --git a/gvsds/apps/gerp/apps/mailbox/apps.gerp_manifests b/gvsds/apps/gerp/apps/mailbox/apps.gerp_manifests new file mode 100644 index 00000000..c8051a71 --- /dev/null +++ b/gvsds/apps/gerp/apps/mailbox/apps.gerp_manifests @@ -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,多个用逗号分隔" } + ] +} diff --git a/gvsds/apps/gerp/mail_manager.py b/gvsds/apps/gerp/apps/mailbox/manager.py similarity index 100% rename from gvsds/apps/gerp/mail_manager.py rename to gvsds/apps/gerp/apps/mailbox/manager.py diff --git a/gvsds/apps/gerp/apps/mailbox/models.py b/gvsds/apps/gerp/apps/mailbox/models.py new file mode 100644 index 00000000..4844dde8 --- /dev/null +++ b/gvsds/apps/gerp/apps/mailbox/models.py @@ -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 diff --git a/gvsds/apps/gerp/apps/mailbox/urls.py b/gvsds/apps/gerp/apps/mailbox/urls.py new file mode 100644 index 00000000..0d285ded --- /dev/null +++ b/gvsds/apps/gerp/apps/mailbox/urls.py @@ -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'), +] diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/apps/mailbox/views.py similarity index 99% rename from gvsds/apps/gerp/views/mail.py rename to gvsds/apps/gerp/apps/mailbox/views.py index a312346e..e5482f94 100644 --- a/gvsds/apps/gerp/views/mail.py +++ b/gvsds/apps/gerp/apps/mailbox/views.py @@ -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__) diff --git a/gvsds/apps/gerp/apps/oa/__init__.py b/gvsds/apps/gerp/apps/oa/__init__.py new file mode 100644 index 00000000..8fcd7dd6 --- /dev/null +++ b/gvsds/apps/gerp/apps/oa/__init__.py @@ -0,0 +1 @@ +"""OA 办公自动化(GERP 子应用)——按 GERP 子应用目录规约组织,迁移仍集中在 gerp/migrations/。""" diff --git a/gvsds/apps/gerp/apps/oa/apps.gerp_manifests b/gvsds/apps/gerp/apps/oa/apps.gerp_manifests new file mode 100644 index 00000000..32b211cd --- /dev/null +++ b/gvsds/apps/gerp/apps/oa/apps.gerp_manifests @@ -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": ["普通员工", "行政专员", "行政主管", "行政经理"] + } + ] +} diff --git a/gvsds/apps/gerp/oa_manager.py b/gvsds/apps/gerp/apps/oa/manager.py similarity index 100% rename from gvsds/apps/gerp/oa_manager.py rename to gvsds/apps/gerp/apps/oa/manager.py diff --git a/gvsds/apps/gerp/apps/oa/models.py b/gvsds/apps/gerp/apps/oa/models.py new file mode 100644 index 00000000..c592dad1 --- /dev/null +++ b/gvsds/apps/gerp/apps/oa/models.py @@ -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 = '工作汇报' diff --git a/gvsds/apps/gerp/apps/oa/urls.py b/gvsds/apps/gerp/apps/oa/urls.py new file mode 100644 index 00000000..86caf1ba --- /dev/null +++ b/gvsds/apps/gerp/apps/oa/urls.py @@ -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'), +] diff --git a/gvsds/apps/gerp/views/oa.py b/gvsds/apps/gerp/apps/oa/views.py similarity index 97% rename from gvsds/apps/gerp/views/oa.py rename to gvsds/apps/gerp/apps/oa/views.py index 2ed070e6..8e1b8f1a 100644 --- a/gvsds/apps/gerp/views/oa.py +++ b/gvsds/apps/gerp/apps/oa/views.py @@ -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): diff --git a/gvsds/apps/gerp/apps/product_order/__init__.py b/gvsds/apps/gerp/apps/product_order/__init__.py new file mode 100644 index 00000000..7679ddb6 --- /dev/null +++ b/gvsds/apps/gerp/apps/product_order/__init__.py @@ -0,0 +1 @@ +"""产品与订单(GERP 子应用)""" diff --git a/gvsds/apps/gerp/apps/product_order/apps.gerp_manifests b/gvsds/apps/gerp/apps/product_order/apps.gerp_manifests new file mode 100644 index 00000000..13b6572f --- /dev/null +++ b/gvsds/apps/gerp/apps/product_order/apps.gerp_manifests @@ -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": ["产品专员", "订单专员", "业务主管", "业务经理"] + } + ] +} diff --git a/gvsds/apps/gerp/apps/shop/__init__.py b/gvsds/apps/gerp/apps/shop/__init__.py new file mode 100644 index 00000000..48ec44a0 --- /dev/null +++ b/gvsds/apps/gerp/apps/shop/__init__.py @@ -0,0 +1 @@ +"""枢机协律-商店(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)""" diff --git a/gvsds/apps/gerp/apps/shop/apps.gerp_manifests b/gvsds/apps/gerp/apps/shop/apps.gerp_manifests new file mode 100644 index 00000000..02390dc5 --- /dev/null +++ b/gvsds/apps/gerp/apps/shop/apps.gerp_manifests @@ -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" + } + ] +} diff --git a/gvsds/apps/gerp/apps/store_value/__init__.py b/gvsds/apps/gerp/apps/store_value/__init__.py new file mode 100644 index 00000000..e4e870bb --- /dev/null +++ b/gvsds/apps/gerp/apps/store_value/__init__.py @@ -0,0 +1 @@ +"""枢机协律-储值(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)""" diff --git a/gvsds/apps/gerp/apps/store_value/apps.gerp_manifests b/gvsds/apps/gerp/apps/store_value/apps.gerp_manifests new file mode 100644 index 00000000..4510d7d4 --- /dev/null +++ b/gvsds/apps/gerp/apps/store_value/apps.gerp_manifests @@ -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" + } + ] +} diff --git a/gvsds/apps/gerp/apps/talk/__init__.py b/gvsds/apps/gerp/apps/talk/__init__.py new file mode 100644 index 00000000..c0a7324c --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/__init__.py @@ -0,0 +1,5 @@ +"""Talk 企业通讯(GERP 子应用) + +按 GERP 子应用目录规约组织:models(app_label='gerp')/ manager / views / urls / +permissions / apps.gerp_manifests 清单声明。迁移仍集中在 gvsds/apps/gerp/migrations/。 +""" diff --git a/gvsds/apps/gerp/apps/talk/apps.gerp_manifests b/gvsds/apps/gerp/apps/talk/apps.gerp_manifests new file mode 100644 index 00000000..9b1cb890 --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/apps.gerp_manifests @@ -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" } + ] +} diff --git a/gvsds/apps/gerp/apps/talk/manager.py b/gvsds/apps/gerp/apps/talk/manager.py new file mode 100644 index 00000000..ca158b63 --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/manager.py @@ -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, + } diff --git a/gvsds/apps/gerp/apps/talk/models.py b/gvsds/apps/gerp/apps/talk/models.py new file mode 100644 index 00000000..c7c2a0ed --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/models.py @@ -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}]' diff --git a/gvsds/apps/gerp/apps/talk/permissions.py b/gvsds/apps/gerp/apps/talk/permissions.py new file mode 100644 index 00000000..d96f1e3d --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/permissions.py @@ -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) diff --git a/gvsds/apps/gerp/apps/talk/urls.py b/gvsds/apps/gerp/apps/talk/urls.py new file mode 100644 index 00000000..45097fb6 --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/urls.py @@ -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()), +] diff --git a/gvsds/apps/gerp/apps/talk/views.py b/gvsds/apps/gerp/apps/talk/views.py new file mode 100644 index 00000000..e166355b --- /dev/null +++ b/gvsds/apps/gerp/apps/talk/views.py @@ -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, + }}) diff --git a/gvsds/apps/gerp/apps/wallet/__init__.py b/gvsds/apps/gerp/apps/wallet/__init__.py new file mode 100644 index 00000000..da4438f9 --- /dev/null +++ b/gvsds/apps/gerp/apps/wallet/__init__.py @@ -0,0 +1 @@ +"""枢机协律-支付系统(CreateE 系 legacy 页面。模型在 gvsds/apps/crb(app_label='crb'),本目录仅承载清单声明)""" diff --git a/gvsds/apps/gerp/apps/wallet/apps.gerp_manifests b/gvsds/apps/gerp/apps/wallet/apps.gerp_manifests new file mode 100644 index 00000000..bc716436 --- /dev/null +++ b/gvsds/apps/gerp/apps/wallet/apps.gerp_manifests @@ -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" + } + ] +} diff --git a/gvsds/apps/gerp/gerp_app.py b/gvsds/apps/gerp/gerp_app.py new file mode 100644 index 00000000..377f8de9 --- /dev/null +++ b/gvsds/apps/gerp/gerp_app.py @@ -0,0 +1,21 @@ +"""GERP 应用的 AppConfig(原 apps.py) + +为何文件名不是 apps.py: +GERP 按子应用目录规约在包内放了 `apps/` 包(gvsds/apps/gerp/apps//), +而 Django 的 AppConfig 自动发现会去找 `.apps`(即 gvsds/apps/gerp/apps), +与 `apps/` 包同名冲突——Python 会把 `gvsds.apps.gerp.apps` 解析为那个包目录, +导致在 app 注册阶段(模型未就绪)就执行包内导入而报 AppRegistryNotReady。 + +解决办法:AppConfig 移到本模块,并在 INSTALLED_APPS 里用显式点分路径注册 +('gvsds.apps.gerp.gerp_app.GerpConfig'),Django 便不再自动探测 `.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 企业管理' diff --git a/gvsds/apps/gerp/migrations/0046_talk_models_state.py b/gvsds/apps/gerp/migrations/0046_talk_models_state.py new file mode 100644 index 00000000..7d2e2a45 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0046_talk_models_state.py @@ -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消息', + }, + ), + ], + ), + ] diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index 679d0584..cc5b59bd 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -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//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()) diff --git a/gvsds/apps/gerp/oidc.py b/gvsds/apps/gerp/oidc.py index 4190e28e..0d2241b0 100644 --- a/gvsds/apps/gerp/oidc.py +++ b/gvsds/apps/gerp/oidc.py @@ -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 diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index d56207e5..ad1e01b8 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -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, ) diff --git a/gvsds/settings.py b/gvsds/settings.py index dd780248..b11d2017 100644 --- a/gvsds/settings.py +++ b/gvsds/settings.py @@ -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', diff --git a/gvsds/urls.py b/gvsds/urls.py index 276e05a1..f609c378 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -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 += [