baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
@@ -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种子数据初始化完成"))