baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
"""演示账套(批次 C2):一键重建可对外演示的经销商数据。
|
||||
|
||||
用法:
|
||||
python manage.py seed_demo # 幂等重建(同一租户可重复执行)
|
||||
python manage.py seed_demo --reset # 先清空演示租户业务数据再重建
|
||||
|
||||
设计:
|
||||
- 固定租户 `demo`(只读演示角色 + 全套数据),登录页"免注册体验"用它。
|
||||
- 数据突出**食品经销场景**:批次效期(近效期/正常期)、整件散包(多单位)、
|
||||
客户信用额度(含超限客户)、应收账龄(六桶都有分布)+ AI 风险客户。
|
||||
- 幂等:所有业务对象按 code/bill_no 做 update_or_create。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.models import Org, Tenant
|
||||
from apps.catalog.models import Brand, Category, Product, Unit, UnitConversion
|
||||
from apps.partner.models import Customer, Supplier
|
||||
from apps.inventory.models import Warehouse
|
||||
from apps.inventory import services as inv_services
|
||||
from apps.notify.services import init_default_alert_rules
|
||||
from apps.purchase import services as purchase_services
|
||||
from apps.sales import services as sales_services
|
||||
|
||||
DEMO_TENANT = "demo"
|
||||
DEMO_USER = "demo"
|
||||
DEMO_PASSWORD = "demo12345"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "创建/重建演示账套(食品经销商场景,幂等)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--reset", action="store_true",
|
||||
help="先清空演示租户的业务数据再重建")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""重建演示账套。
|
||||
|
||||
注意:**不**在 handle 上包一个总事务。两个原因:
|
||||
1. 单张历史单失败要能独立回滚(保存点),外层大事务会让失败"污染"整个事务;
|
||||
2. 数据量大(近百张单)时,单一长事务在 PG 上容易触碰锁/内存上限。
|
||||
每一步自身的事务由 services 层的 `@transaction.atomic` 保证。
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
tenant, _ = Tenant.objects.get_or_create(
|
||||
code=DEMO_TENANT,
|
||||
defaults={"name": "演示经销商(食品)", "contact": "演示账号",
|
||||
"phone": "13800138000", "is_active": True},
|
||||
)
|
||||
Org.objects.get_or_create(tenant=tenant, code="hq",
|
||||
defaults={"name": "总部", "org_path": "001"})
|
||||
|
||||
if options["reset"]:
|
||||
self._reset(tenant)
|
||||
|
||||
user = self._user(tenant)
|
||||
units = self._units(tenant)
|
||||
products = self._products(tenant, units)
|
||||
warehouse = self._warehouse(tenant)
|
||||
customers = self._customers(tenant)
|
||||
supplier = self._supplier(tenant)
|
||||
|
||||
self._purchase(tenant, supplier, warehouse, products, today)
|
||||
# 历史销售需要充足库存:先补一批"历史进货"(不建单据,直接入库,速度快)
|
||||
self._seed_history_stock(tenant, warehouse, products, today)
|
||||
self._sales(tenant, customers, warehouse, products, today)
|
||||
self._payment_history(tenant, customers, today)
|
||||
self._receivables_aging(tenant, customers, today)
|
||||
self._notify_rules(tenant)
|
||||
# Keep the demo tenant immediately usable for ledger and report pages.
|
||||
from apps.finance import services as finance_services
|
||||
finance_services.init_chart_of_accounts(tenant)
|
||||
finance_services.ensure_periods(
|
||||
tenant,
|
||||
today - timedelta(days=120),
|
||||
today + timedelta(days=120),
|
||||
)
|
||||
self._storefront(tenant)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"\n演示账套就绪:租户 {DEMO_TENANT} / 账号 {DEMO_USER} / 密码 {DEMO_PASSWORD}"
|
||||
))
|
||||
self.stdout.write(" 登录页点“免注册体验演示账套”即可进入(只读演示角色)")
|
||||
|
||||
# ---------------- 组件 ----------------
|
||||
|
||||
def _reset(self, tenant):
|
||||
from apps.finance.models import (
|
||||
Allocation, Payable, Receipt, Payment, Receivable, StatementShare,
|
||||
Voucher, VoucherEntry,
|
||||
)
|
||||
from apps.inventory.models import Stock, StockBatch, StockMovement
|
||||
from apps.purchase.models import PurchaseBill, PurchaseBillLine
|
||||
from apps.sales.models import SalesBill, SalesBillLine
|
||||
from apps.notify.models import Notification
|
||||
from apps.storefront.models import (
|
||||
CustomerProductAuth, StorefrontAccount, StorefrontOrder,
|
||||
StorefrontOrderLine,
|
||||
)
|
||||
|
||||
# Delete dependent storefront rows before their parent orders/accounts.
|
||||
# Reset is expected to remove the complete demo storefront, not just
|
||||
# inventory and accounting rows, so stale customer catalog access or
|
||||
# submitted orders cannot survive a rebuild.
|
||||
for model in (Allocation, Receipt, Payment, VoucherEntry, Voucher,
|
||||
StatementShare, Receivable, Payable,
|
||||
SalesBillLine, SalesBill, PurchaseBillLine, PurchaseBill,
|
||||
StorefrontOrderLine, StorefrontOrder, CustomerProductAuth,
|
||||
StorefrontAccount, StockMovement, StockBatch, Stock,
|
||||
Notification):
|
||||
model.objects.filter(tenant=tenant).delete()
|
||||
self.stdout.write(" · 已清空演示租户业务数据")
|
||||
|
||||
def _user(self, tenant):
|
||||
User = get_user_model()
|
||||
user, created = User.objects.get_or_create(
|
||||
username=DEMO_USER,
|
||||
defaults={"is_staff": False, "is_superuser": False,
|
||||
"first_name": "演示", "last_name": "账号"},
|
||||
)
|
||||
if created or not user.check_password(DEMO_PASSWORD):
|
||||
user.set_password(DEMO_PASSWORD)
|
||||
user.save()
|
||||
self.stdout.write(f" {'✓ 创建' if created else '· 复用'} 演示账号 {DEMO_USER}")
|
||||
return user
|
||||
|
||||
def _units(self, tenant):
|
||||
# 基本单位:瓶(饮料乳品)/ 袋(零食粮油)/ 公斤(散装称重)
|
||||
specs = [
|
||||
("bottle", "瓶", True), ("bag", "袋", True), ("kg", "公斤", True),
|
||||
("box", "箱", False), ("carton", "件", False),
|
||||
("pack", "提", False), ("barrel", "桶", False),
|
||||
]
|
||||
out = {}
|
||||
for code, name, is_base in specs:
|
||||
u, _ = Unit.objects.update_or_create(
|
||||
tenant=tenant, code=code, defaults={"name": name, "is_base": is_base}
|
||||
)
|
||||
out[code] = u
|
||||
return out
|
||||
|
||||
def _products(self, tenant, units):
|
||||
"""食品经销商商品档案:22 个 SKU,覆盖 4 大类。
|
||||
|
||||
特意覆盖的特性组合(对应系统能力):
|
||||
- 多单位(瓶/箱、kg/袋)
|
||||
- 批次效期管理(鲜奶/酸奶/面包)
|
||||
- 称重商品(散装糖、散装米)
|
||||
- 最低售价管控(高毛利品与促销品)
|
||||
- 不同税率(13% 标准 / 9% 农产品 / 0 免税)
|
||||
"""
|
||||
cats = {}
|
||||
for code, name in [
|
||||
("drink", "饮料冲调"), ("fresh", "生鲜乳品"),
|
||||
("snack", "休闲零食"), ("grain", "粮油干货"),
|
||||
]:
|
||||
cats[code], _ = Category.objects.update_or_create(
|
||||
tenant=tenant, code=code, defaults={"name": name}
|
||||
)
|
||||
|
||||
brands = {}
|
||||
for code, name in [
|
||||
("nf", "农夫山泉"), ("gm", "光明"), ("yl", "伊利"),
|
||||
("wlm", "旺旺"), ("jly", "金龙鱼"), ("other", "自有品牌"),
|
||||
]:
|
||||
brands[code], _ = Brand.objects.update_or_create(
|
||||
tenant=tenant, code=code, defaults={"name": name}
|
||||
)
|
||||
|
||||
# (编码, 名称, 规格, 品类, 品牌, 基本单位, 成本, 售价, 最低价,
|
||||
# 是否批次, 保质期天, 条码, 税率, 换算[(单位, rate)])
|
||||
specs = [
|
||||
# ---- 饮料冲调(多单位,无批次)----
|
||||
("P1001", "农夫山泉 550ml", "550ml×24瓶", "drink", "nf", "bottle",
|
||||
"1.10", "2.00", "1.60", False, None, "6921168509256", "0.13",
|
||||
[("box", "24"), ("pack", "6")]),
|
||||
("P1002", "农夫山泉 1.5L", "1.5L×12瓶", "drink", "nf", "bottle",
|
||||
"2.40", "4.00", "3.20", False, None, "6921168593521", "0.13",
|
||||
[("box", "12")]),
|
||||
("P1003", "农夫山泉 4L桶装", "4L×6桶", "drink", "nf", "bottle",
|
||||
"6.50", "9.90", "8.50", False, None, "6921168599134", "0.13",
|
||||
[("box", "6")]),
|
||||
("P1004", "东方树叶 500ml", "500ml×15瓶", "drink", "nf", "bottle",
|
||||
"2.80", "4.50", "3.80", False, None, "6921168509124", "0.13",
|
||||
[("box", "15")]),
|
||||
("P1005", "尖叫运动饮料", "550ml×15瓶", "drink", "nf", "bottle",
|
||||
"3.20", "5.00", "4.20", False, None, "6921168541001", "0.13",
|
||||
[("box", "15")]),
|
||||
|
||||
# ---- 生鲜乳品(批次效期,短保)----
|
||||
("P2001", "光明 鲜牛奶 950ml", "950ml×12瓶", "fresh", "gm", "bottle",
|
||||
"9.00", "15.50", "12.00", True, 21, "6923644271234", "0.13",
|
||||
[("box", "12")]),
|
||||
("P2002", "光明 优倍鲜奶 280ml", "280ml×24瓶", "fresh", "gm", "bottle",
|
||||
"4.20", "7.50", "6.00", True, 15, "6923644275678", "0.13",
|
||||
[("box", "24")]),
|
||||
("P2003", "光明 酸奶 100g×8", "100g×8杯", "fresh", "gm", "bottle",
|
||||
"8.00", "13.90", "11.00", True, 21, "6923644281000", "0.13",
|
||||
[("box", "12")]),
|
||||
("P2004", "伊利 纯牛奶 250ml", "250ml×24盒", "fresh", "yl", "bottle",
|
||||
"2.60", "4.20", "3.50", True, 180, "6907992820856", "0.13",
|
||||
[("box", "24")]),
|
||||
("P2005", "伊利 安慕希 205g", "205g×12盒", "fresh", "yl", "bottle",
|
||||
"5.50", "8.90", "7.20", True, 150, "6907992826667", "0.13",
|
||||
[("box", "12")]),
|
||||
|
||||
# ---- 休闲零食(多单位 + 部分批次)----
|
||||
("P3001", "旺旺雪饼 84g", "84g×40袋", "snack", "wlm", "bag",
|
||||
"2.10", "3.50", "2.80", False, None, "6924743915428", "0.13",
|
||||
[("box", "40")]),
|
||||
("P3002", "旺旺仙贝 84g", "84g×40袋", "snack", "wlm", "bag",
|
||||
"2.10", "3.50", "2.80", False, None, "6924743915435", "0.13",
|
||||
[("box", "40")]),
|
||||
("P3003", "乐事薯片 原味 70g", "70g×24袋", "snack", "other", "bag",
|
||||
"3.80", "6.00", "5.00", False, None, "6924743901111", "0.13",
|
||||
[("box", "24")]),
|
||||
("P3004", "桃李面包 手撕包", "400g×12袋", "snack", "other", "bag",
|
||||
"6.50", "10.50", "8.50", True, 7, "6924743902222", "0.13",
|
||||
[("box", "12")]),
|
||||
("P3005", "奥利奥夹心饼干 116g", "116g×24袋", "snack", "other", "bag",
|
||||
"4.20", "6.80", "5.60", False, None, "6901668005701", "0.13",
|
||||
[("box", "24")]),
|
||||
|
||||
# ---- 粮油干货(称重商品 + 低税率)----
|
||||
("P4001", "金龙鱼 调和油 5L", "5L×4桶", "grain", "jly", "bottle",
|
||||
"52.00", "69.90", "62.00", False, None, "6902088201195", "0.09",
|
||||
[("box", "4")]),
|
||||
("P4002", "金龙鱼 大米 10kg", "10kg/袋", "grain", "jly", "bag",
|
||||
"48.00", "62.00", "55.00", False, None, "6902088202211", "0.09",
|
||||
[]),
|
||||
("P4003", "散装冰糖(称重)", "散装", "grain", "other", "kg",
|
||||
"5.00", "9.90", "8.00", False, None, "", "0.09", []),
|
||||
("P4004", "散装东北大米(称重)", "散装", "grain", "other", "kg",
|
||||
"4.20", "6.80", "5.60", False, None, "", "0.09", []),
|
||||
("P4005", "白砂糖 1kg装", "1kg×20袋", "grain", "other", "bag",
|
||||
"6.50", "9.50", "8.00", False, None, "6902088203333", "0.09",
|
||||
[("box", "20")]),
|
||||
("P4006", "有机杂粮礼盒", "2kg/盒", "grain", "other", "bag",
|
||||
"68.00", "128.00", "108.00", False, None, "6902088204444", "0.09",
|
||||
[]),
|
||||
("P4007", "绿豆 500g", "500g×30袋", "grain", "other", "bag",
|
||||
"5.80", "8.50", "7.00", False, None, "6902088205555", "0.09",
|
||||
[("box", "30")]),
|
||||
]
|
||||
|
||||
out = {}
|
||||
for (code, name, spec, cat, brand, base, cost, sale, min_sale,
|
||||
batch, shelf, barcode, tax, convs) in specs:
|
||||
p, _ = Product.objects.update_or_create(
|
||||
tenant=tenant, code=code,
|
||||
defaults=dict(
|
||||
name=name, spec=spec, barcode=barcode,
|
||||
category=cats[cat], brand=brands[brand],
|
||||
base_unit=units[base],
|
||||
cost_price=Decimal(cost), sale_price=Decimal(sale),
|
||||
min_sale_price=Decimal(min_sale),
|
||||
is_batch_managed=batch, shelf_life_days=shelf,
|
||||
tax_rate=Decimal(tax),
|
||||
status="active",
|
||||
),
|
||||
)
|
||||
for unit_code, rate in convs:
|
||||
UnitConversion.objects.update_or_create(
|
||||
tenant=tenant, product=p, unit=units[unit_code],
|
||||
defaults={"rate": Decimal(rate)},
|
||||
)
|
||||
out[code] = p
|
||||
|
||||
n_batch = sum(1 for x in specs if x[9])
|
||||
n_multi = sum(1 for x in specs if x[13])
|
||||
self.stdout.write(
|
||||
f" ✓ {len(out)} 个商品({n_batch} 个批次管理 / {n_multi} 个多单位 / "
|
||||
f"3 档税率)"
|
||||
)
|
||||
return out
|
||||
|
||||
def _warehouse(self, tenant):
|
||||
from apps.inventory.models import Warehouse as W
|
||||
|
||||
wh, _ = W.objects.update_or_create(
|
||||
tenant=tenant, code="WH01",
|
||||
defaults={"name": "主仓库", "is_default": True, "is_active": True},
|
||||
)
|
||||
W.objects.update_or_create(
|
||||
tenant=tenant, code="WH02",
|
||||
defaults={"name": "冷藏库", "is_active": True},
|
||||
)
|
||||
return wh
|
||||
|
||||
def _customers(self, tenant):
|
||||
"""15 个客户,覆盖信用分层与风险画像(供 AI 风控与账龄演示)。
|
||||
|
||||
画像设计(对应 AI 三因子):
|
||||
- 优质客户:额度高、回款快(低风险)
|
||||
- 普通客户:额度中等、偶有拖欠
|
||||
- 风险客户:额度紧、长期欠款、开单频次下降(高风险)
|
||||
"""
|
||||
specs = [
|
||||
# (编码, 名称, 联系人, 电话, 信用额度, 备注画像)
|
||||
("C1001", "永辉超市(连锁)", "王采购", "13900000001", "80000", "KA 大客户,回款快"),
|
||||
("C1002", "家乐福 中关村店", "李经理", "13900000002", "40000", "KA 大客户"),
|
||||
("C1003", "社区便利-张老板", "张老板", "13900000003", "40000", "多频次小单"),
|
||||
("C1004", "大学城食堂采购", "赵主任", "13900000004", "60000", "账期稳定"),
|
||||
("C1005", "城东批发部(风险户)", "钱老板", "13900000005", "6000", "长期欠款,警示样本"),
|
||||
("C1006", "阳光超市 五道口店", "孙店长", "13900000006", "25000", "中频客户"),
|
||||
("C1007", "好邻居便利店 连锁", "周采购", "13900000007", "35000", "连锁,量稳"),
|
||||
("C1008", "某科技园食堂", "吴经理", "13900000008", "20000", "新客户"),
|
||||
("C1009", "西城区实验中学", "郑老师", "13900000009", "30000", "学期性采购"),
|
||||
("C1010", "老刘杂货铺", "刘老板", "13900000010", "8000", "小户,现金为主"),
|
||||
("C1011", "美宜佳 加盟店 A", "陈店长", "13900000011", "18000", "加盟体系"),
|
||||
("C1012", "美宜佳 加盟店 B", "褚店长", "13900000012", "18000", "加盟体系"),
|
||||
("C1013", "机关食堂配送商", "卫经理", "13900000013", "50000", "B 端食堂"),
|
||||
("C1014", "深夜便利 24h 店", "蒋店长", "13900000014", "12000", "夜间配送"),
|
||||
("C1015", "城中村小卖部", "沈老板", "13900000015", "3000", "小额高频"),
|
||||
]
|
||||
out = {}
|
||||
for code, name, contact, phone, limit, _note in specs:
|
||||
c, _ = Customer.objects.update_or_create(
|
||||
tenant=tenant, code=code,
|
||||
defaults=dict(name=name, contact_name=contact, phone=phone,
|
||||
credit_limit=Decimal(limit), is_active=True),
|
||||
)
|
||||
out[code] = c
|
||||
|
||||
tiers = {"高额度(≥4万)": sum(1 for x in specs if Decimal(x[4]) >= 40000),
|
||||
"中额度(1-4万)": sum(1 for x in specs if 10000 <= Decimal(x[4]) < 40000),
|
||||
"低额度(<1万)": sum(1 for x in specs if Decimal(x[4]) < 10000)}
|
||||
self.stdout.write(
|
||||
f" ✓ {len(out)} 个客户(信用分层:"
|
||||
+ " / ".join(f"{k} {v}个" for k, v in tiers.items()) + ")"
|
||||
)
|
||||
return out
|
||||
|
||||
def _supplier(self, tenant):
|
||||
s, _ = Supplier.objects.update_or_create(
|
||||
tenant=tenant, code="S1001",
|
||||
defaults={"name": "华东食品供应链有限公司",
|
||||
"contact_name": "王经理", "phone": "0571-88886666"},
|
||||
)
|
||||
return s
|
||||
|
||||
# ---------------- 业务数据 ----------------
|
||||
|
||||
def _purchase(self, tenant, supplier, warehouse, products, today):
|
||||
"""两批进货:一批建批次(近效期 + 正常期),一批普通商品。"""
|
||||
# 批次商品:光明鲜奶(近效期)+ 优倍(正常期)
|
||||
bills = [
|
||||
dict(no="PB-DEMO-001", day=30, lines=[
|
||||
dict(p="P1001", qty=400, price="1.05", unit="box"),
|
||||
dict(p="P1002", qty=200, price="2.30", unit="box"),
|
||||
]),
|
||||
dict(no="PB-DEMO-002", day=18, lines=[
|
||||
dict(p="P2001", qty=200, price="8.80", unit="box",
|
||||
batch="B2026-MILK-A", prod_delta=3, expiry_delta=10,
|
||||
source_unit="box"),
|
||||
dict(p="P2002", qty=400, price="4.00", unit="box",
|
||||
batch="B2026-MILK-B", prod_delta=1, expiry_delta=14),
|
||||
]),
|
||||
dict(no="PB-DEMO-003", day=6, lines=[
|
||||
dict(p="P1001", qty=200, price="1.08", unit="box"),
|
||||
# 旺旺雪饼按袋进货(基本单位袋;无箱换算,不能写箱)
|
||||
dict(p="P3001", qty=500, price="2.10"),
|
||||
]),
|
||||
]
|
||||
|
||||
from apps.purchase.models import PurchaseBill
|
||||
|
||||
created = 0
|
||||
for spec in bills:
|
||||
if PurchaseBill.objects.filter(tenant=tenant, bill_no=spec["no"]).exists():
|
||||
continue
|
||||
lines = []
|
||||
for ln in spec["lines"]:
|
||||
product = products[ln["p"]]
|
||||
row = {"product": product, "quantity": ln["qty"],
|
||||
"unit_price": ln["price"]}
|
||||
if ln.get("unit"):
|
||||
unit = Unit.objects.filter(
|
||||
tenant=tenant, code=ln["unit"]
|
||||
).first()
|
||||
if unit is not None:
|
||||
row["source_unit"] = unit
|
||||
if ln.get("batch"):
|
||||
row["batch_no"] = ln["batch"]
|
||||
row["production_date"] = today - timedelta(days=ln["prod_delta"])
|
||||
row["expiry_date"] = today + timedelta(days=ln["expiry_delta"])
|
||||
lines.append(row)
|
||||
|
||||
bill = purchase_services.create_purchase_bill(
|
||||
tenant=tenant, supplier=supplier, warehouse=warehouse,
|
||||
bill_date=today - timedelta(days=spec["day"]),
|
||||
lines=lines, remark="演示数据",
|
||||
)
|
||||
# 服务层自生成单号;演示数据改写为固定单号,保证重复执行时幂等
|
||||
if bill.bill_no != spec["no"]:
|
||||
PurchaseBill.objects.filter(pk=bill.pk).update(bill_no=spec["no"])
|
||||
bill.refresh_from_db()
|
||||
purchase_services.confirm_purchase_bill(bill)
|
||||
created += 1
|
||||
self.stdout.write(f" ✓ {created} 张进货单已过账(含批次效期)")
|
||||
|
||||
def _sales(self, tenant, customers, warehouse, products, today):
|
||||
"""销售单据:6 个月历史 + 本月在手单,支撑报表趋势与 AI 风险分析。
|
||||
|
||||
结构:
|
||||
- 固定剧本单(保证幂等与可预期):覆盖多单位/抹零/批次/风险户
|
||||
- 生成式历史单:过去 5 个月每月若干单,让利润表/排行/趋势有数据
|
||||
"""
|
||||
from apps.sales.models import SalesBill # noqa: F401(幂等回写单号用)
|
||||
|
||||
rng = random.Random(20260901) # 固定种子:每次重建结果一致
|
||||
|
||||
# ---------- 固定剧本单(幂等) ----------
|
||||
specs = [
|
||||
dict(no="XS-DEMO-001", cust="C1001", day=28, round_to="1", lines=[
|
||||
dict(p="P1001", qty=50, unit="box", price="45.00"),
|
||||
dict(p="P1002", qty=20, unit="box", price="46.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-002", cust="C1002", day=21, round_to="0.1", lines=[
|
||||
dict(p="P1003", qty=30, unit="box", price="55.00"),
|
||||
dict(p="P4003", qty=15, price="9.90"),
|
||||
]),
|
||||
# 批次商品销售(FEFO 跨批分摊)
|
||||
dict(no="XS-DEMO-003", cust="C1003", day=14, round_to="1", lines=[
|
||||
dict(p="P2001", qty=20, unit="box", price="180.00"),
|
||||
dict(p="P3004", qty=10, unit="box", price="120.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-004", cust="C1004", day=9, round_to="1", lines=[
|
||||
dict(p="P2002", qty=40, unit="box", price="176.00"),
|
||||
dict(p="P1001", qty=15, unit="box", price="47.00"),
|
||||
dict(p="P4002", qty=10, price="62.00"),
|
||||
]),
|
||||
# 风险客户:大额赊销、不回款
|
||||
dict(no="XS-DEMO-005", cust="C1005", day=45, round_to="1", lines=[
|
||||
dict(p="P1001", qty=20, unit="box", price="46.00"),
|
||||
dict(p="P1002", qty=10, unit="box", price="47.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-006", cust="C1005", day=8, round_to="1", lines=[
|
||||
dict(p="P4003", qty=60, price="9.90"),
|
||||
]),
|
||||
# 少量多次客户
|
||||
dict(no="XS-DEMO-007", cust="C1015", day=11, round_to="1", lines=[
|
||||
dict(p="P3001", qty=2, unit="box", price="138.00"),
|
||||
dict(p="P3002", qty=2, unit="box", price="138.00"),
|
||||
dict(p="P3003", qty=1, unit="box", price="142.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-008", cust="C1007", day=6, round_to="1", lines=[
|
||||
dict(p="P1004", qty=10, unit="box", price="66.00"),
|
||||
dict(p="P1005", qty=10, unit="box", price="74.00"),
|
||||
dict(p="P2004", qty=20, unit="box", price="99.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-009", cust="C1013", day=3, round_to="1", lines=[
|
||||
dict(p="P4001", qty=8, unit="box", price="276.00"),
|
||||
dict(p="P4002", qty=20, price="62.00"),
|
||||
dict(p="P4005", qty=10, unit="box", price="188.00"),
|
||||
]),
|
||||
dict(no="XS-DEMO-010", cust="C1009", day=2, round_to="1", lines=[
|
||||
dict(p="P2005", qty=30, unit="box", price="105.00"),
|
||||
dict(p="P3005", qty=15, unit="box", price="160.00"),
|
||||
]),
|
||||
]
|
||||
|
||||
created = 0
|
||||
for spec in specs:
|
||||
if SalesBill.objects.filter(tenant=tenant, bill_no=spec["no"]).exists():
|
||||
continue
|
||||
lines = []
|
||||
for ln in spec["lines"]:
|
||||
row = {"product": products[ln["p"]], "quantity": ln["qty"],
|
||||
"unit_price": ln["price"]}
|
||||
if ln.get("unit"):
|
||||
unit = Unit.objects.filter(tenant=tenant, code=ln["unit"]).first()
|
||||
if unit is not None:
|
||||
row["source_unit"] = unit
|
||||
lines.append(row)
|
||||
|
||||
bill = sales_services.create_sales_bill(
|
||||
tenant=tenant, customer=customers[spec["cust"]],
|
||||
warehouse=warehouse,
|
||||
bill_date=today - timedelta(days=spec["day"]),
|
||||
lines=lines, round_to=spec["round_to"], remark="演示数据",
|
||||
)
|
||||
if bill.bill_no != spec["no"]:
|
||||
SalesBill.objects.filter(pk=bill.pk).update(bill_no=spec["no"])
|
||||
bill.refresh_from_db()
|
||||
try:
|
||||
sales_services.confirm_sales_bill(bill)
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
# 库存/额度不足:**保留草稿**(不删)。
|
||||
# 删除会让下次执行的 exists() 检查落空 → 重新创建 → 破坏幂等。
|
||||
# 保留草稿既保证幂等,也把"数据配置不足"暴露在界面上便于排查。
|
||||
self.stderr.write(
|
||||
f" · {spec['no']} 未过账({type(exc).__name__}),保留草稿"
|
||||
)
|
||||
|
||||
# ---------- 生成式历史单(过去 5 个月) ----------
|
||||
hist_created = self._sales_history(
|
||||
tenant, customers, warehouse, products, today, rng
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f" ✓ {created} 张剧本销售单 + {hist_created} 张历史销售单"
|
||||
f"(含多单位/抹零/批次/赊销)"
|
||||
)
|
||||
|
||||
def _topup_stock_for_lines(self, tenant, warehouse, lines):
|
||||
"""按单据行需求补货(历史单重试前调用)。
|
||||
|
||||
补到"当前需求 + 安全余量",保证重试必定成功。
|
||||
"""
|
||||
from apps.inventory.models import Stock
|
||||
|
||||
for ln in lines:
|
||||
product = ln["product"]
|
||||
need = Decimal(str(ln["quantity"]))
|
||||
current = Stock.objects.filter(
|
||||
tenant=tenant, warehouse=warehouse, product=product
|
||||
).first()
|
||||
have = current.on_hand if current else Decimal("0")
|
||||
shortfall = need + Decimal("50") - have
|
||||
if shortfall <= 0:
|
||||
continue
|
||||
cost = Decimal(str(product.cost_price))
|
||||
if getattr(product, "is_batch_managed", False):
|
||||
inv_services.inbound(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
quantity=shortfall, unit_cost=cost,
|
||||
batch_no=f"B{product.code}-TOPUP",
|
||||
expiry_date=date.today() + timedelta(
|
||||
days=max((product.shelf_life_days or 30) - 2, 1)),
|
||||
source_type="manual", source_ref="SEED-TOPUP",
|
||||
)
|
||||
else:
|
||||
inv_services.inbound(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
quantity=shortfall, unit_cost=cost,
|
||||
source_type="manual", source_ref="SEED-TOPUP",
|
||||
)
|
||||
|
||||
def _seed_history_stock(self, tenant, warehouse, products, today_local=None):
|
||||
"""为历史销售准备库存(直接走 inbound,不生成进货单据)。
|
||||
|
||||
幂等:以目标库存量为准,不足则补足;已有足够库存则跳过。
|
||||
"""
|
||||
from apps.inventory.models import Stock
|
||||
|
||||
# 目标库存:按"剧本单需求 + 历史单消耗"留足余量(约 2 倍需求)
|
||||
targets = {
|
||||
"P1001": Decimal("2500"), "P1002": Decimal("900"),
|
||||
"P1003": Decimal("500"), "P1004": Decimal("500"),
|
||||
"P1005": Decimal("500"), "P2001": Decimal("600"),
|
||||
"P2002": Decimal("1500"), "P2003": Decimal("400"),
|
||||
"P2004": Decimal("1200"), "P2005": Decimal("900"),
|
||||
"P3001": Decimal("600"), "P3002": Decimal("600"),
|
||||
"P3003": Decimal("500"), "P3004": Decimal("400"),
|
||||
"P3005": Decimal("800"), "P4001": Decimal("300"),
|
||||
"P4002": Decimal("600"), "P4003": Decimal("500"),
|
||||
"P4005": Decimal("500"), "P4006": Decimal("120"),
|
||||
"P4007": Decimal("400"),
|
||||
}
|
||||
topped = 0
|
||||
for code, target in targets.items():
|
||||
product = products.get(code)
|
||||
if product is None:
|
||||
continue
|
||||
current = Stock.objects.filter(
|
||||
tenant=tenant, warehouse=warehouse, product=product
|
||||
).first()
|
||||
have = current.on_hand if current else Decimal("0")
|
||||
if have >= target:
|
||||
continue
|
||||
|
||||
need = target - have
|
||||
cost = Decimal(str(product.cost_price))
|
||||
if getattr(product, "is_batch_managed", False):
|
||||
# 批次商品:拆成 2 个批次入库(一个近效期、一个正常期),
|
||||
# 便于演示 FEFO 分摊与临期预警
|
||||
shelf = product.shelf_life_days or 30
|
||||
half = (need / 2).quantize(Decimal("0.0001"))
|
||||
plan = [
|
||||
(f"B{code}-A", half,
|
||||
today_local - timedelta(days=max(shelf - 10, 1)),
|
||||
today_local + timedelta(days=min(10, shelf - 1))),
|
||||
(f"B{code}-B", need - half,
|
||||
today_local,
|
||||
today_local + timedelta(days=max(shelf - 2, 1))),
|
||||
]
|
||||
for batch_no, qty, prod_date, exp_date in plan:
|
||||
if qty <= 0:
|
||||
continue
|
||||
inv_services.inbound(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
quantity=qty, unit_cost=cost,
|
||||
batch_no=batch_no, production_date=prod_date,
|
||||
expiry_date=exp_date,
|
||||
source_type="manual", source_ref="SEED-HISTORY",
|
||||
)
|
||||
else:
|
||||
inv_services.inbound(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
quantity=need, unit_cost=cost,
|
||||
source_type="manual", source_ref="SEED-HISTORY",
|
||||
)
|
||||
topped += 1
|
||||
if topped:
|
||||
self.stdout.write(f" ✓ 补齐 {topped} 个商品的历史库存")
|
||||
|
||||
def _sales_history(self, tenant, customers, warehouse, products, today, rng):
|
||||
"""过去 5 个月的历史销售单(让报表趋势/排行/账龄有真实数据)。
|
||||
|
||||
**幂等的关键**:每张单的参数用**只依赖单号的独立随机源**生成,
|
||||
不能用共享的 `rng` 顺序取值。
|
||||
|
||||
踩过的坑:最初用共享 rng,第二次执行时"已存在的单"会 `continue`
|
||||
而跳过 rng 调用,导致后续单拿到**不同的随机值** → 生成不同的客户/商品/数量
|
||||
→ 同一份 seed 跑两次得到不同数据(表现为数量从 86 变 96)。
|
||||
改成 `Random(hash(bill_no))` 后,任何一张单的参数都是确定的,
|
||||
与"之前有没有单被跳过"完全无关。
|
||||
"""
|
||||
def _rng_for(bill_no: str):
|
||||
"""每张单据独立的确定性随机源。"""
|
||||
return random.Random(f"dealerhub-seed-{bill_no}")
|
||||
from apps.sales.models import SalesBill
|
||||
|
||||
# 参与历史销售的客户:排除风险户(它的额度画像留给剧本单制造高风险特征)。
|
||||
# 额度问题由 confirm(force=True) 解决,不在此处做客户筛选。
|
||||
hist_customers = [c for code, c in customers.items() if code != "C1005"]
|
||||
# 可售商品池(排除称重散装,避免小数数量)
|
||||
sellable = [(code, p) for code, p in products.items()
|
||||
if p.base_unit and p.base_unit.code != "kg"]
|
||||
|
||||
created = 0
|
||||
skipped_non_stock = 0
|
||||
skipped_stock = 0
|
||||
for month_offset in range(1, 6): # 1..5 月前
|
||||
month_date = (today.replace(day=1) - timedelta(days=30 * month_offset))
|
||||
month_key = month_date.strftime("%Y%m")
|
||||
for seq in range(1, 19): # 每月最多 18 张,具体张数由参数决定
|
||||
bill_no = f"XS-H{month_key}-{seq:03d}"
|
||||
if SalesBill.objects.filter(tenant=tenant, bill_no=bill_no).exists():
|
||||
continue
|
||||
|
||||
# 每张单用自己的随机源(与执行顺序无关)
|
||||
r = _rng_for(bill_no)
|
||||
if seq > r.randint(12, 18): # 该月实际单据数
|
||||
continue
|
||||
|
||||
customer = r.choice(hist_customers)
|
||||
bill_date = month_date + timedelta(days=r.randint(0, 27))
|
||||
|
||||
# 每单 1-3 行
|
||||
lines = []
|
||||
for _ in range(r.randint(1, 3)):
|
||||
code, product = r.choice(sellable)
|
||||
qty = r.choice([2, 3, 5, 8, 10, 15, 20])
|
||||
# 用默认售价(不传价 → 自动取价,顺便验证取价链路)
|
||||
lines.append({"product": product, "quantity": qty})
|
||||
|
||||
# 随机量**必须在重试之前取好**:
|
||||
# 若放在 try 里,失败重试会多消耗一次 rng → 后续所有单据的
|
||||
# 客户/商品/数量全部错位 → 同一份 seed 两次执行结果不同(幂等破坏)。
|
||||
round_to = r.choice([None, "1", "0.1"])
|
||||
|
||||
def _try_create():
|
||||
bill = sales_services.create_sales_bill(
|
||||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||||
bill_date=bill_date, lines=lines,
|
||||
round_to=round_to, remark="历史数据",
|
||||
)
|
||||
if bill.bill_no != bill_no:
|
||||
SalesBill.objects.filter(pk=bill.pk).update(bill_no=bill_no)
|
||||
bill.refresh_from_db()
|
||||
# force=True:历史单据是"已发生的事实",不应被**当前**的额度策略拦截
|
||||
# (否则首次执行会因额度不足跳过部分单,第二次额度占用不同又补上 → 幂等破坏)
|
||||
sales_services.confirm_sales_bill(bill, force=True)
|
||||
return bill
|
||||
|
||||
# 单张单独立事务:失败只影响这一张,不污染后续
|
||||
try:
|
||||
with transaction.atomic():
|
||||
_try_create()
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
is_stock = ("InsufficientStock" in type(exc).__name__
|
||||
or "stock" in str(exc).lower())
|
||||
if not is_stock:
|
||||
# 额度不足等非库存问题:跳过(数据配置层面应避免)
|
||||
skipped_non_stock += 1
|
||||
continue
|
||||
# 库存不足:当场补货后重试一次,保证首次执行就全部成功
|
||||
# (首次全成功 → 第二次 exists() 全命中 → 幂等自然成立)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self._topup_stock_for_lines(tenant, warehouse, lines)
|
||||
_try_create()
|
||||
created += 1
|
||||
except Exception:
|
||||
skipped_stock += 1
|
||||
continue
|
||||
return created
|
||||
|
||||
def _payment_history(self, tenant, customers, today):
|
||||
"""历史回款记录(AI 风控"回款周期漂移"因子的数据源)。
|
||||
|
||||
设计画像(让风险分有区分度):
|
||||
- 优质客户(C1001/C1002/C1004):历史回款 7-15 天,近期仍快 → 低风险
|
||||
- 普通客户(C1003/C1007/C1013):历史 20-30 天,近期略慢 → 中低
|
||||
- 风险客户(C1005):历史 10 天,**近期拖到 60+ 天** → 高风险
|
||||
(回款周期因子 + 欠款上升因子 + 长期未开单三重命中)
|
||||
|
||||
幂等:收款单号 `SK-HIST-{客户}-{序号}`,已存在则跳过。
|
||||
"""
|
||||
from apps.finance.models import Allocation, Receipt, Receivable
|
||||
|
||||
# (客户, [(开单距今天数, 回款距今天数)])
|
||||
#
|
||||
# 关键:AI 的"回款周期漂移"因子用**近 90 天样本**对比**历史基线**,
|
||||
# 因此每个客户都必须有落在 90 天内的核销记录,否则该因子不计分(样本不足)。
|
||||
profiles = {
|
||||
# 优质:回款快(开单后 10-15 天收齐),近期仍快 → 因子 0 分
|
||||
"C1001": [(300, 285), (200, 186), (80, 66), (50, 36)],
|
||||
"C1002": [(280, 262), (180, 163), (75, 60), (45, 31)],
|
||||
"C1004": [(260, 246), (160, 145), (70, 56), (40, 27)],
|
||||
# 普通:20-30 天,近期与历史持平 → 因子 0 分
|
||||
"C1003": [(270, 243), (170, 142), (85, 58), (55, 29)],
|
||||
"C1007": [(250, 220), (150, 121), (78, 50), (48, 21)],
|
||||
"C1013": [(240, 208), (140, 109), (72, 43), (42, 14)],
|
||||
# 风险户:历史 10-15 天(快),**近 90 天内严重拖到 60-85 天**
|
||||
# → 周期漂移因子拉满,叠加欠款上升 → 高风险
|
||||
"C1005": [(320, 305), (250, 236), (85, 25), (62, 8)],
|
||||
}
|
||||
|
||||
created = 0
|
||||
for cust_code, records in profiles.items():
|
||||
customer = customers.get(cust_code)
|
||||
if customer is None:
|
||||
continue
|
||||
for idx, (bill_ago, paid_ago) in enumerate(records, start=1):
|
||||
bill_no = f"RC-HIST-{cust_code}-{idx:02d}"
|
||||
if Receivable.objects.filter(tenant=tenant, bill_no=bill_no).exists():
|
||||
continue
|
||||
|
||||
amount = Decimal("2000") + Decimal(idx * 500)
|
||||
bill_date = today - timedelta(days=bill_ago)
|
||||
paid_date = today - timedelta(days=paid_ago)
|
||||
|
||||
recv = Receivable.objects.create(
|
||||
tenant=tenant, customer=customer, bill_no=bill_no,
|
||||
source_type="manual", source_ref="演示历史账",
|
||||
bill_date=bill_date, due_date=bill_date + timedelta(days=30),
|
||||
total_amount=amount, paid_amount=amount,
|
||||
status="paid",
|
||||
)
|
||||
receipt = Receipt.objects.create(
|
||||
tenant=tenant, customer=customer,
|
||||
bill_no=f"SK-HIST-{cust_code}-{idx:02d}",
|
||||
bill_date=paid_date, amount=amount,
|
||||
method="银行", status="posted",
|
||||
)
|
||||
Allocation.objects.create(
|
||||
tenant=tenant, kind="receipt", receivable=recv,
|
||||
receipt=receipt, amount=amount,
|
||||
)
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
self.stdout.write(
|
||||
f" ✓ {created} 笔历史回款(含回款周期画像:优质 7-15 天 / "
|
||||
f"风险户历史快但近期拖长)"
|
||||
)
|
||||
return created
|
||||
|
||||
def _receivables_aging(self, tenant, customers, today):
|
||||
"""造出六桶都有分布的账龄 + 部分收款,让账龄图不是一条柱子。"""
|
||||
from apps.finance.models import Allocation, Receipt, Receivable
|
||||
|
||||
specs = [
|
||||
# (客户, 金额, 开单距今天数, 已收金额)
|
||||
("C1001", "8000", 250, "0"), # 181-365
|
||||
("C1002", "6000", 120, "2000"), # 91-180
|
||||
("C1003", "3000", 75, "0"), # 61-90
|
||||
("C1004", "4000", 45, "1000"), # 31-60
|
||||
("C1005", "2500", 10, "0"), # 0-30
|
||||
("C1006", "5000", 500, "0"), # 365+
|
||||
]
|
||||
created = 0
|
||||
for idx, (cust_code, amount, days_ago, paid) in enumerate(specs, start=1):
|
||||
bill_no = f"RC-DEMO-{idx:03d}"
|
||||
if Receivable.objects.filter(tenant=tenant, bill_no=bill_no).exists():
|
||||
continue
|
||||
paid_d = Decimal(paid)
|
||||
recv = Receivable.objects.create(
|
||||
tenant=tenant, customer=customers[cust_code], bill_no=bill_no,
|
||||
source_type="manual", source_ref="演示历史账",
|
||||
bill_date=today - timedelta(days=days_ago),
|
||||
due_date=today - timedelta(days=max(days_ago - 30, 0)),
|
||||
total_amount=Decimal(amount), paid_amount=paid_d,
|
||||
status="paid" if paid_d >= Decimal(amount) else
|
||||
("partial" if paid_d > 0 else "open"),
|
||||
)
|
||||
if paid_d > 0:
|
||||
rc = Receipt.objects.create(
|
||||
tenant=tenant, customer=customers[cust_code],
|
||||
bill_no=f"SK-DEMO-{idx:03d}",
|
||||
bill_date=today - timedelta(days=max(days_ago - 20, 1)),
|
||||
amount=paid_d, method="银行", status="posted",
|
||||
)
|
||||
Allocation.objects.create(
|
||||
tenant=tenant, kind="receipt", receivable=recv,
|
||||
receipt=rc, amount=paid_d,
|
||||
)
|
||||
created += 1
|
||||
self.stdout.write(f" ✓ {created} 笔历史应收(覆盖六档账龄:0-30 ~ 365+)")
|
||||
|
||||
def _storefront(self, tenant):
|
||||
"""商城账号 + 商品授权(D1):演示客户可用手机号登录 H5 自助下单。
|
||||
|
||||
商城是专业版功能(billing.storefront 开关),所以演示租户要挂专业版订阅,
|
||||
否则客户登录会被功能开关拦掉。
|
||||
"""
|
||||
from apps.billing.models import Plan, Subscription, seed_plans
|
||||
|
||||
seed_plans()
|
||||
pro = Plan.objects.filter(code="pro", is_active=True).first()
|
||||
if pro is not None:
|
||||
sub = Subscription.objects.filter(tenant=tenant).first()
|
||||
if sub is None:
|
||||
Subscription.objects.create(
|
||||
tenant=tenant, plan=pro, status="active",
|
||||
period_start=date.today(),
|
||||
period_end=date.today() + timedelta(days=365),
|
||||
remark="演示账套:专业版(含订货商城)",
|
||||
)
|
||||
else:
|
||||
sub.plan = pro
|
||||
sub.status = "active"
|
||||
sub.period_end = date.today() + timedelta(days=365)
|
||||
sub.save(update_fields=["plan", "status", "period_end", "updated_at"])
|
||||
|
||||
from apps.storefront.services import seed_demo_storefront
|
||||
|
||||
out = seed_demo_storefront(tenant)
|
||||
self.stdout.write(
|
||||
f" ✓ 商城账号 {out['accounts']} 个 / 商品授权 {out['auths']} 条"
|
||||
f"(登录密码 store12345)"
|
||||
)
|
||||
|
||||
def _notify_rules(self, tenant):
|
||||
init_default_alert_rules(tenant)
|
||||
from apps.notify.models import AlertRule
|
||||
|
||||
AlertRule.objects.update_or_create(
|
||||
tenant=tenant, code="batch_expiry",
|
||||
defaults={"name": "批次近效期预警(30 天)", "rule_type": "batch_expiry",
|
||||
"threshold": Decimal("30"), "is_enabled": True,
|
||||
"channels": ["in_app"]},
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""初始化种子数据命令。
|
||||
|
||||
用法:
|
||||
python manage.py seed_initial_data
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.core.models import Tenant, Org, TenantMembership
|
||||
|
||||
|
||||
DEFAULT_TENANTS = [
|
||||
{"code": "default", "name": "默认租户", "contact": "系统管理员", "phone": ""},
|
||||
{"code": "demo", "name": "演示经销商", "contact": "演示账号", "phone": "13800138000"},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "初始化种子数据:默认租户、示例组织等"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for t_data in DEFAULT_TENANTS:
|
||||
tenant, created = Tenant.objects.get_or_create(
|
||||
code=t_data["code"],
|
||||
defaults={
|
||||
"name": t_data["name"],
|
||||
"contact": t_data["contact"],
|
||||
"phone": t_data["phone"],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
status = "✓ 创建" if created else "· 已存在"
|
||||
self.stdout.write(f"{status} 租户: {tenant.code} ({tenant.name})")
|
||||
|
||||
# 给每个租户创建一个默认组织
|
||||
org, org_created = Org.objects.get_or_create(
|
||||
tenant=tenant,
|
||||
code="hq",
|
||||
defaults={
|
||||
"name": "总部",
|
||||
"org_path": "001",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
org_status = "✓ 创建" if org_created else "· 已存在"
|
||||
self.stdout.write(f" {org_status} 组织: {org.code} ({org.name})")
|
||||
|
||||
# Preserve the historical bootstrap contract: existing superusers are
|
||||
# operators for every seeded tenant, while normal users receive the
|
||||
# configured default tenant only. Explicit memberships remain the
|
||||
# source of truth for all other tenant access.
|
||||
User = get_user_model()
|
||||
default_tenants = list(
|
||||
Tenant.objects.filter(code__in=[t["code"] for t in DEFAULT_TENANTS])
|
||||
)
|
||||
for user in User.objects.all():
|
||||
tenants = default_tenants if user.is_superuser else [
|
||||
t for t in default_tenants if t.code == "default"
|
||||
]
|
||||
for tenant in tenants:
|
||||
TenantMembership.objects.get_or_create(
|
||||
user=user, tenant=tenant,
|
||||
defaults={"role": "owner" if user.is_superuser else "member"},
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("\n种子数据初始化完成"))
|
||||
Reference in New Issue
Block a user