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:
1 parent
bcf733174a
commit
68feaa10a9
9 files changed
+665
No files matched your search
@@ -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}'
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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': '订阅组成员公司',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user