feat(gerp): 新增「订阅式软件提供」应用

订阅组 = 一个产品,为产品指定一家或多家提供者公司/消费者公司;可选公司范围 = 当前管理员可管理的全部公司(跨租户管理员可选任意租户)。

- 新增 subscription 子应用:models/manager/views/urls/manifest
  - SubscriptionGroup / SubscriptionGroupCompany(PartyType 1提供者/0消费者)
  - 校验:产品名必填且同租户唯一、提供者/消费者各至少一家、同一产品内不可双重身份、越权公司拒绝
- 新增迁移 0050_subscription
- urls 挂载 API/Subscription/;app_manifests 登记父菜单「订阅式软件提供」
- 修复 CompanyRows 中 UUIDObj/bytes 键不匹配导致公司名为空
- 修复 SaveGroup 的 AllowedCompanyUUIDs 未归一(UUIDObj 无 hex)
This commit is contained in:
TermiNexus committed 2026-10-02 21:36:10 +08:00
1 parent bcf733174a
commit 68feaa10a9
9 files changed
+665

No files matched your search

+1
View File
@@ -355,6 +355,7 @@ PARENT_MENU_TITLES = {
'product-order': ('产品与订单', 'Goods', 3),
'ehrm': ('人力资源管理扩展', 'UserFilled', 4),
'oa': ('办公自动化', 'Files', 5),
'subscription': ('订阅式软件提供', 'Connection', 12),
}
@@ -0,0 +1,9 @@
"""订阅式软件提供(subscription)子应用
一个 GERP 应用 = 本目录。目录内固定结构见上级包 `gvsds/apps/gerp/apps/__init__.py` 的说明:
models.py 该应用的模型(app_label 仍为 'gerp',迁移集中在 gerp/migrations/)
manager.py 该应用的数据访问层
views.py 该应用的视图
urls.py 该应用的路由(在 gvsds/urls.py 内 include)
apps.gerp_manifests 应用清单声明(由 app_manifests.py 扫描加载)
"""
@@ -0,0 +1,29 @@
{
"$schema": "../gerp_manifests.schema.json",
"AppCode": "subscription",
"AppName": "订阅式软件提供",
"AppIcon": "Connection",
"AppDesc": "以「订阅组(产品)」为单位登记软件提供关系:为每个产品指定提供者公司与消费者公司,并汇总订阅关系",
"AppType": "enterprise",
"AppSort": 13,
"FrontendRoute": "/subscription/group",
"FrontendType": "vue",
"Menus": [
{
"MenuName": "subscription-group",
"MenuTitle": "订阅组管理",
"MenuLink": "/subscription/group",
"MenuIcon": "Connection",
"MenuSort": 1,
"ParentMenuName": "subscription"
},
{
"MenuName": "subscription-relation",
"MenuTitle": "订阅关系",
"MenuLink": "/subscription/relation",
"MenuIcon": "Share",
"MenuSort": 2,
"ParentMenuName": "subscription"
}
]
}
@@ -0,0 +1,334 @@
"""模块:订阅式软件提供数据访问层
视图层不直接操作 ORM;所有方法遵循帕斯卡命名法。
术语:
订阅组(SubscriptionGroup)= 一个「产品」;
组内公司分两类(SubscriptionGroupCompany.PartyType):
1 = 提供者 / 0 = 消费者
"""
import datetime
import uuid
from django.db import transaction
from gvsdsdk.model_utils import UUIDObj
from .models import PARTY_CONSUMER, PARTY_PROVIDER, SubscriptionGroup, SubscriptionGroupCompany
def _ToBytes(value):
"""UUIDObj / bytes / uuid.UUID / str → BINARY(16) bytes;无法转换返回 None"""
if value is None:
return None
if isinstance(value, bytes):
return value
if hasattr(value, 'bytes'):
try:
return value.bytes
except Exception:
return None
try:
return uuid.UUID(str(value)).bytes
except (ValueError, AttributeError, TypeError):
return None
def _ToStr(value):
if value is None:
return ''
if isinstance(value, bytes):
try:
return str(UUIDObj(value))
except Exception:
return ''
try:
return str(UUIDObj(value))
except Exception:
return str(value)
class SubscriptionManager:
"""订阅组(产品)与组成员公司"""
# ==================== 公司 / 租户展示信息 ====================
@staticmethod
def CompanyRows(CompanyUUIDs):
"""把公司 UUID 列表(bytes)补全为展示行(含公司名与所属租户名)
返回顺序与入参一致(查不到的公司跳过)。
"""
from gvsds.apps.gerp.models import Company, Tenant
# 入参可能是 UUIDObj / bytes / str,统一归一成 BINARY(16) bytes 后再查,
# 否则 by_uuid(bytes 键)与循环变量(UUIDObj)不匹配,会全部查不到。
wanted = [_ToBytes(c) for c in (CompanyUUIDs or [])]
wanted = [c for c in wanted if c]
if not wanted:
return []
companies = list(Company.objects.filter(CompanyUUID__in=wanted))
by_uuid = {_ToBytes(c.CompanyUUID): c for c in companies}
tenant_ids = [c.TenantUUID for c in companies if c.TenantUUID]
tenant_names = {
_ToStr(t.TenantUUID): t.TenantName
for t in Tenant.objects.filter(TenantUUID__in=[t for t in tenant_ids if t])
}
rows = []
for company_bytes in wanted:
company = by_uuid.get(company_bytes)
if company is None:
continue
tenant_str = _ToStr(company.TenantUUID)
rows.append({
'CompanyUUID': _ToStr(company.CompanyUUID),
'CompanyName': company.CompanyName,
'CompanyShort': company.CompanyShort or '',
'TenantUUID': tenant_str,
'TenantName': tenant_names.get(tenant_str, ''),
})
return rows
@staticmethod
def _MemberRows(GroupUUIDs):
"""{组UUID(str): {'Providers': [...], 'Consumers': [...]}} 公司展示行"""
rows = list(SubscriptionGroupCompany.objects.filter(
SubscriptionGroupUUID__in=[_ToBytes(g) for g in GroupUUIDs]))
if not rows:
return {}
info = {
row['CompanyUUID']: row
for row in SubscriptionManager.CompanyRows([r.CompanyUUID for r in rows])
}
result = {}
for r in rows:
key = _ToStr(r.SubscriptionGroupUUID)
bucket = result.setdefault(key, {'Providers': [], 'Consumers': []})
info_row = info.get(_ToStr(r.CompanyUUID))
if info_row is None:
info_row = {
'CompanyUUID': _ToStr(r.CompanyUUID),
'CompanyName': '',
'CompanyShort': '',
'TenantUUID': _ToStr(r.TenantUUID),
'TenantName': '',
}
target = 'Providers' if r.PartyType == PARTY_PROVIDER else 'Consumers'
bucket[target].append(info_row)
return result
@staticmethod
def _GroupRow(group, members):
bucket = members.get(_ToStr(group.SubscriptionGroupUUID), {'Providers': [], 'Consumers': []})
return {
'SubscriptionGroupUUID': _ToStr(group.SubscriptionGroupUUID),
'TenantUUID': _ToStr(group.TenantUUID),
'GroupName': group.GroupName,
'GroupDesc': group.GroupDesc or '',
'GroupStatus': group.GroupStatus,
'CreateTime': group.CreateTime.isoformat() if group.CreateTime else None,
'Providers': bucket['Providers'],
'Consumers': bucket['Consumers'],
}
# ==================== 订阅组(产品) ====================
@staticmethod
def ListGroups(TenantUUID=None):
"""列出订阅组(含提供者/消费者公司名单)
TenantUUID 为 None → 全部(跨租户管理员视角);否则仅该租户创建的组。
"""
qs = SubscriptionGroup.objects.all()
if TenantUUID is not None:
qs = qs.filter(TenantUUID=_ToBytes(TenantUUID))
groups = list(qs)
if not groups:
return []
members = SubscriptionManager._MemberRows([g.SubscriptionGroupUUID for g in groups])
return [SubscriptionManager._GroupRow(g, members) for g in groups]
@staticmethod
def GetGroup(GroupUUID, TenantUUID=None):
"""取单个组(含成员名单);越范围返回 None"""
group_bytes = _ToBytes(GroupUUID)
if group_bytes is None:
return None
qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes)
if TenantUUID is not None:
qs = qs.filter(TenantUUID=_ToBytes(TenantUUID))
group = qs.first()
if group is None:
return None
members = SubscriptionManager._MemberRows([group.SubscriptionGroupUUID])
return SubscriptionManager._GroupRow(group, members)
@staticmethod
def SaveGroup(TenantUUID, Data, OperatorUUID=None, AllowedCompanyUUIDs=None):
"""新建/更新一个订阅组,并整体重写其提供者/消费者公司名单
Args:
TenantUUID: 归属租户(bytes 或 str);新建时写入
Data: {SubscriptionGroupUUID?, GroupName, GroupDesc?, GroupStatus?, Providers:[], Consumers:[]}
OperatorUUID: 创建人(仅新建时写)
AllowedCompanyUUIDs: 允许选用的公司 UUID 集合(bytes);None=不校验(调用方须自行保证)
Returns:
(group_row, error) —— 校验失败时 group_row 为 None
"""
group_name = str(Data.get('GroupName') or '').strip()
if not group_name:
return None, '请填写产品名称'
if len(group_name) > 128:
return None, '产品名称过长(最多 128 字)'
provider_bytes = [_ToBytes(x) for x in (Data.get('Providers') or [])]
consumer_bytes = [_ToBytes(x) for x in (Data.get('Consumers') or [])]
provider_bytes = [c for c in provider_bytes if c]
consumer_bytes = [c for c in consumer_bytes if c]
if not provider_bytes:
return None, '请至少指定一家提供者公司'
if not consumer_bytes:
return None, '请至少指定一家消费者公司'
if len(set(provider_bytes)) != len(provider_bytes) or len(set(consumer_bytes)) != len(consumer_bytes):
return None, '同一家公司在同一身份下只能出现一次'
if provider_bytes and consumer_bytes:
overlap = {c.hex() for c in provider_bytes} & {c.hex() for c in consumer_bytes}
if overlap:
return None, '同一家公司不能在同一产品中既是提供者又是消费者'
if AllowedCompanyUUIDs is not None:
# 允许集合同样可能是 UUIDObj(AppManager.GetManageableCompanies 返回),先归一
allowed_bytes = [_ToBytes(c) for c in AllowedCompanyUUIDs]
allowed = {c.hex() for c in allowed_bytes if c}
outside = [c for c in (provider_bytes + consumer_bytes) if c.hex() not in allowed]
if outside:
return None, '存在不在您管理范围内的公司,请重新选择'
group_bytes = _ToBytes(Data.get('SubscriptionGroupUUID'))
existing = None
if group_bytes is not None:
qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes)
if TenantUUID is not None:
qs = qs.filter(TenantUUID=_ToBytes(TenantUUID))
existing = qs.first()
if existing is None:
return None, '订阅组不存在或不在您的管理范围'
# 同租户内产品名唯一
dup = SubscriptionGroup.objects.filter(
TenantUUID=_ToBytes(TenantUUID), GroupName=group_name)
if existing is not None:
dup = dup.exclude(SubscriptionGroupUUID=existing.SubscriptionGroupUUID)
if dup.exists():
return None, f'已存在同名产品「{group_name}」'
status = Data.get('GroupStatus')
status = 1 if status is None else (1 if int(status) else 0)
desc = str(Data.get('GroupDesc') or '').strip()[:512]
with transaction.atomic():
if existing is None:
group = SubscriptionGroup(
SubscriptionGroupUUID=uuid.uuid4().bytes,
TenantUUID=_ToBytes(TenantUUID),
GroupName=group_name,
GroupDesc=desc,
GroupStatus=status,
CreatorUUID=_ToBytes(OperatorUUID),
)
group.save()
else:
group = existing
group.GroupName = group_name
group.GroupDesc = desc
group.GroupStatus = status
group.save(update_fields=['GroupName', 'GroupDesc', 'GroupStatus'])
# 成员名单整体重写(组内成员数量有限,重写比 diff 更简单可靠)
SubscriptionGroupCompany.objects.filter(
SubscriptionGroupUUID=group.SubscriptionGroupUUID).delete()
now = datetime.datetime.utcnow()
for party_type, values in ((PARTY_PROVIDER, provider_bytes), (PARTY_CONSUMER, consumer_bytes)):
for company_bytes in values:
SubscriptionGroupCompany.objects.create(
SubscriptionGroupCompanyUUID=uuid.uuid4().bytes,
SubscriptionGroupUUID=group.SubscriptionGroupUUID,
CompanyUUID=company_bytes,
TenantUUID=_ToBytes(SubscriptionManager._CompanyTenant(company_bytes)),
PartyType=party_type,
CreateTime=now,
)
members = SubscriptionManager._MemberRows([group.SubscriptionGroupUUID])
return SubscriptionManager._GroupRow(group, members), None
@staticmethod
def _CompanyTenant(CompanyUUID):
"""公司所属租户 UUID(查不到返回 None)"""
from gvsds.apps.gerp.models import Company
company = Company.objects.filter(CompanyUUID=CompanyUUID).first()
return company.TenantUUID if company else None
@staticmethod
def DeleteGroup(GroupUUID, TenantUUID=None):
"""删除订阅组及其成员公司名单
Returns:
(成功?, 错误消息)
"""
group_bytes = _ToBytes(GroupUUID)
if group_bytes is None:
return False, '无效的订阅组 UUID'
qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes)
if TenantUUID is not None:
qs = qs.filter(TenantUUID=_ToBytes(TenantUUID))
group = qs.first()
if group is None:
return False, '订阅组不存在或不在您的管理范围'
with transaction.atomic():
SubscriptionGroupCompany.objects.filter(
SubscriptionGroupUUID=group.SubscriptionGroupUUID).delete()
group.delete()
return True, ''
# ==================== 订阅关系(按公司汇总) ====================
@staticmethod
def ListRelations(TenantUUID=None):
"""订阅关系展示:按公司汇总它作为「提供者 / 消费者」参与的产品
TenantUUID 为 None → 全部;否则只统计该租户创建的组。
"""
rows = SubscriptionManager.ListGroups(TenantUUID=TenantUUID)
index = {}
def _bucket(company):
key = company.get('CompanyUUID') or ''
entry = index.setdefault(key, {
'CompanyUUID': company.get('CompanyUUID') or '',
'CompanyName': company.get('CompanyName') or '',
'CompanyShort': company.get('CompanyShort') or '',
'TenantUUID': company.get('TenantUUID') or '',
'TenantName': company.get('TenantName') or '',
'ProviderGroups': [],
'ConsumerGroups': [],
})
return entry
for group in rows:
for company in group.get('Providers') or []:
_bucket(company)['ProviderGroups'].append({
'SubscriptionGroupUUID': group['SubscriptionGroupUUID'],
'GroupName': group['GroupName'],
'GroupStatus': group['GroupStatus'],
})
for company in group.get('Consumers') or []:
_bucket(company)['ConsumerGroups'].append({
'SubscriptionGroupUUID': group['SubscriptionGroupUUID'],
'GroupName': group['GroupName'],
'GroupStatus': group['GroupStatus'],
})
return list(index.values())
@@ -0,0 +1,76 @@
"""订阅式软件提供模型(app_label 保持 'gerp',迁移集中在 gerp/migrations/)
术语:
订阅组(SubscriptionGroup)= 一个「产品」;
组内公司分两类(SubscriptionGroupCompany.PartyType):
1 = 提供者(提供该产品/服务)
0 = 消费者(订阅该产品/服务)
同一家公司可以既是 A 产品的提供者,又是 B 产品的消费者。
"""
import datetime
from django.db import models
from gvsds.apps.core.models import UUIDField
from gvsds.model_base import QModel
#: 组成员公司的一方类型
PARTY_CONSUMER = 0
PARTY_PROVIDER = 1
class SubscriptionGroup(QModel):
"""订阅组(对应一个产品)——订阅式软件提供的基本单位
TenantUUID 为创建时的租户上下文(用于数据隔离);跨租户管理员(admin:super)
创建组时可选其它租户的公司作为提供者/消费者,故组成员公司不限于本租户。
"""
SubscriptionGroupUUID = UUIDField(primary_key=True, verbose_name='订阅组UUID')
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', db_index=True, verbose_name='归属租户UUID')
GroupName = models.CharField(max_length=128, verbose_name='产品名称')
GroupDesc = models.CharField(max_length=512, default='', verbose_name='产品说明')
GroupStatus = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0停用)')
CreatorUUID = UUIDField(null=True, blank=True, db_column='CreatorUUID', verbose_name='创建人UUID')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'SubscriptionGroup'
app_label = 'gerp'
ordering = ['-CreateTime']
# 同租户内产品名唯一(跨租户可同名)
unique_together = (('TenantUUID', 'GroupName'),)
verbose_name = '订阅组'
verbose_name_plural = '订阅组'
def __str__(self):
return self.GroupName
class SubscriptionGroupCompany(QModel):
"""订阅组成员公司——组内的提供者 / 消费者
PartyType:1=提供者 / 0=消费者。
TenantUUID 冗余存该公司的所属租户:跨租户管理员可选多家租户的公司,展示时据此分组。
"""
SubscriptionGroupCompanyUUID = UUIDField(primary_key=True, verbose_name='成员UUID')
SubscriptionGroupUUID = UUIDField(
db_column='SubscriptionGroupUUID', db_index=True, verbose_name='订阅组UUID')
CompanyUUID = UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID')
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='公司所属租户UUID')
PartyType = models.SmallIntegerField(default=PARTY_CONSUMER, verbose_name='一方类型(1提供者/0消费者)')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'SubscriptionGroupCompany'
app_label = 'gerp'
ordering = ['PartyType', 'CreateTime']
# 同一组内,同一家公司同一身份只允许一条
unique_together = (('SubscriptionGroupUUID', 'CompanyUUID', 'PartyType'),)
verbose_name = '订阅组成员公司'
verbose_name_plural = '订阅组成员公司'
def __str__(self):
return f'{self.SubscriptionGroupUUID} → {self.CompanyUUID}'
+16
View File
@@ -0,0 +1,16 @@
"""订阅式软件提供(subscription)路由
挂载点:`gvsds/urls.py` → `path('API/Subscription/', include('gvsds.apps.gerp.apps.subscription.urls'))`
"""
from django.urls import path
from . import views
urlpatterns = [
path('Groups', views.SubscriptionGroupListView.as_view(), name='subscription_groups'),
path('Group/Save', views.SubscriptionGroupSaveView.as_view(), name='subscription_group_save'),
path('Group/Delete', views.SubscriptionGroupDeleteView.as_view(), name='subscription_group_delete'),
path('Relations', views.SubscriptionRelationView.as_view(), name='subscription_relations'),
path('Companies', views.SubscriptionCompanyView.as_view(), name='subscription_companies'),
]
+141
View File
@@ -0,0 +1,141 @@
"""模块:订阅式软件提供视图
URL 路径最后一段作为 action 名称:
GET /API/Subscription/Groups - 订阅组(产品)列表(含提供者/消费者公司)
POST /API/Subscription/Group/Save - 新建/更新订阅组(整体重写公司名单)
POST /API/Subscription/Group/Delete - 删除订阅组
GET /API/Subscription/Relations - 订阅关系(按公司汇总其提供/消费的产品)
GET /API/Subscription/Companies - 可选公司列表(当前管理员全部可管理公司,含所属租户)
权限:管理员(admin:super / admin:tenant / admin:company)。
公司可选范围 = AppManager.GetManageableCompanies:
admin:super → 全部租户全部公司(跨租户)
admin:tenant → 本租户全部公司
admin:company → 被授权公司
订阅组归属创建时的租户上下文(列表按租户隔离;admin:super 看全部)。
"""
import logging
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from gvsdsdk.auth.permissions import IsAdminUser
from gvsdsdk.model_utils import UUIDObj
from gvsds.apps.gerp.app_manager import AppManager
from gvsds.apps.gerp.views import ELTAuthenticatedAPIView
from .manager import SubscriptionManager
logger = logging.getLogger(__name__)
def _ToBytes(value):
if value is None:
return None
if isinstance(value, bytes):
return value
if hasattr(value, 'bytes'):
try:
return value.bytes
except Exception:
return None
try:
return UUIDObj(str(value)).bytes
except Exception:
return None
def _ResolveScope(request):
"""解析当前管理员的公司可选范围与订阅组归属租户
Returns:
(AllowedCompanyUUIDs: list[bytes], Scope: str, OwnerTenantUUID: bytes|None, AllTenants: bool)
Scope 为空字符串表示无管理员权限。
"""
companies, scope = AppManager.GetManageableCompanies(request)
all_tenants = scope == 'all'
owner_tenant = _ToBytes(getattr(request, 'current_tenant_uuid', None))
return companies, scope, owner_tenant, all_tenants
class SubscriptionGroupListView(ELTAuthenticatedAPIView):
"""订阅组(产品)列表"""
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
companies, scope, owner_tenant, all_tenants = _ResolveScope(request)
if not scope:
return Response({'code': 403, 'msg': '无权访问订阅组:您不是管理员'}, status=403)
rows = SubscriptionManager.ListGroups(TenantUUID=None if all_tenants else owner_tenant)
return Response({
'code': 0,
'data': {'list': rows, 'total': len(rows), 'Scope': scope},
})
class SubscriptionGroupSaveView(ELTAuthenticatedAPIView):
"""新建 / 更新订阅组(公司名单整体重写)"""
permission_classes = [IsAuthenticated, IsAdminUser]
def post(self, request):
companies, scope, owner_tenant, all_tenants = _ResolveScope(request)
if not scope:
return Response({'code': 403, 'msg': '无权保存订阅组:您不是管理员'}, status=403)
row, err = SubscriptionManager.SaveGroup(
TenantUUID=owner_tenant,
Data=request.data,
OperatorUUID=getattr(request.user, 'UserUUID', None),
# 跨租户管理员可选任意公司;其余限定在其可管理公司内(防越权指定)
AllowedCompanyUUIDs=None if all_tenants else companies,
)
if err:
return Response({'code': 1, 'msg': err}, status=400)
return Response({'code': 0, 'msg': '保存成功', 'data': row})
class SubscriptionGroupDeleteView(ELTAuthenticatedAPIView):
"""删除订阅组(连带成员公司名单)"""
permission_classes = [IsAuthenticated, IsAdminUser]
def post(self, request):
_companies, scope, owner_tenant, all_tenants = _ResolveScope(request)
if not scope:
return Response({'code': 403, 'msg': '无权删除订阅组:您不是管理员'}, status=403)
ok, err = SubscriptionManager.DeleteGroup(
request.data.get('SubscriptionGroupUUID'),
TenantUUID=None if all_tenants else owner_tenant,
)
if not ok:
return Response({'code': 1, 'msg': err}, status=400)
return Response({'code': 0, 'msg': '删除成功'})
class SubscriptionRelationView(ELTAuthenticatedAPIView):
"""订阅关系:按公司汇总它作为提供者 / 消费者参与的产品"""
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
_companies, scope, owner_tenant, all_tenants = _ResolveScope(request)
if not scope:
return Response({'code': 403, 'msg': '无权查看订阅关系:您不是管理员'}, status=403)
rows = SubscriptionManager.ListRelations(TenantUUID=None if all_tenants else owner_tenant)
return Response({'code': 0, 'data': {'list': rows, 'total': len(rows), 'Scope': scope}})
class SubscriptionCompanyView(ELTAuthenticatedAPIView):
"""可选公司列表(含所属租户名),用于提供者/消费者选择器"""
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
companies, scope, _owner_tenant, _all_tenants = _ResolveScope(request)
if not scope:
return Response({'code': 403, 'msg': '无权查看公司列表:您不是管理员'}, status=403)
rows = SubscriptionManager.CompanyRows(companies)
return Response({'code': 0, 'data': {'list': rows, 'total': len(rows), 'Scope': scope}})
@@ -0,0 +1,56 @@
"""订阅式软件提供(subscription):订阅组(产品) + 组成员公司(提供者/消费者)
一个「订阅组」= 一个产品;组内指定一家或多家公司作为提供者、一家或多家作为消费者。
"""
import datetime
from django.db import migrations, models
import gvsds.apps.core.models
class Migration(migrations.Migration):
dependencies = [
('gerp', '0049_employee_profile_id_card'),
]
operations = [
migrations.CreateModel(
name='SubscriptionGroup',
fields=[
('SubscriptionGroupUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='订阅组UUID')),
('TenantUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='TenantUUID', db_index=True, null=True, verbose_name='归属租户UUID')),
('GroupName', models.CharField(max_length=128, verbose_name='产品名称')),
('GroupDesc', models.CharField(default='', max_length=512, verbose_name='产品说明')),
('GroupStatus', models.SmallIntegerField(default=1, verbose_name='状态(1启用/0停用)')),
('CreatorUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='CreatorUUID', null=True, verbose_name='创建人UUID')),
('CreateTime', models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')),
],
options={
'db_table': 'SubscriptionGroup',
'ordering': ['-CreateTime'],
'unique_together': {('TenantUUID', 'GroupName')},
'verbose_name': '订阅组',
'verbose_name_plural': '订阅组',
},
),
migrations.CreateModel(
name='SubscriptionGroupCompany',
fields=[
('SubscriptionGroupCompanyUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='成员UUID')),
('SubscriptionGroupUUID', gvsds.apps.core.models.UUIDField(db_column='SubscriptionGroupUUID', db_index=True, 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')),
('PartyType', models.SmallIntegerField(default=0, verbose_name='一方类型(1提供者/0消费者)')),
('CreateTime', models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')),
],
options={
'db_table': 'SubscriptionGroupCompany',
'ordering': ['PartyType', 'CreateTime'],
'unique_together': {('SubscriptionGroupUUID', 'CompanyUUID', 'PartyType')},
'verbose_name': '订阅组成员公司',
'verbose_name_plural': '订阅组成员公司',
},
),
]
+3
View File
@@ -207,6 +207,9 @@ urlpatterns = [
# 办公自动化流程(OA 审批)——路由在子应用内(apps/oa/urls.py)
path('API/Oa/', include('gvsds.apps.gerp.apps.oa.urls')),
# 订阅式软件提供(订阅组=产品:提供者/消费者公司)——路由在子应用内(apps/subscription/urls.py)
path('API/Subscription/', include('gvsds.apps.gerp.apps.subscription.urls')),
# GERP系统 运维管理(仅系统管理员 admin:super)
path('API/GERPSystem/Status', gerp_views.GERPSystemStatusView.as_view(), name='gerp_system_status'),
path('API/GERPSystem/Schedules', gerp_views.GERPSystemSchedulesView.as_view(), name='gerp_system_schedules'),