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
+182
View File
@@ -0,0 +1,182 @@
"""P0 #6 账龄分析 + 客户对账单 + 公开分享链接集成测试。"""
import pytest
from datetime import date, timedelta
from decimal import Decimal
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product
from apps.inventory.models import Warehouse
from apps.partner.models import Customer
from apps.finance import services as fin_services
from apps.finance.models import Receivable, Receipt, StatementShare
from apps.sales import services as sales_services
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def anon_client(db):
return APIClient()
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def product(db, tenant):
return baker.make(Product, tenant=tenant, code="P001", name="商品A",
sale_price=Decimal("100"))
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="账龄客户")
def _recv(tenant, customer, *, days_ago, total, paid=0, ref="RC-X"):
return baker.make(
Receivable, tenant=tenant, customer=customer, bill_no=ref,
source_type="sale", source_ref=ref, bill_date=date.today() - timedelta(days=days_ago),
total_amount=Decimal(str(total)), paid_amount=Decimal(str(paid)),
status="partial" if paid else "open",
)
# ---------- 账龄 ----------
def test_aging_buckets(tenant, customer):
_recv(tenant, customer, days_ago=10, total=100, ref="RC-A") # 0-30
_recv(tenant, customer, days_ago=45, total=200, paid=50, ref="RC-B") # 31-60 → 150
_recv(tenant, customer, days_ago=100, total=300, ref="RC-C") # 91-180
_recv(tenant, customer, days_ago=400, total=400, ref="RC-D") # 365+
data = fin_services.receivable_aging(tenant)
amounts = {b["bucket"]: b["amount"] for b in data["buckets"]}
assert amounts["0-30"] == "100.0000"
assert amounts["31-60"] == "150.0000"
assert amounts["61-90"] == "0.0000"
assert amounts["91-180"] == "300.0000"
assert amounts["181-365"] == "0.0000"
assert amounts["365+"] == "400.0000"
assert data["total"] == "950.0000"
def test_aging_paid_receivable_excluded(tenant, customer):
_recv(tenant, customer, days_ago=5, total=999, paid=999, ref="RC-P")
data = fin_services.receivable_aging(tenant)
assert data["total"] == "0.0000"
def test_aging_per_customer(tenant, customer):
other = baker.make(Customer, tenant=tenant, code="C002", name="别家")
_recv(tenant, customer, days_ago=10, total=100, ref="RC-A")
_recv(tenant, other, days_ago=10, total=500, ref="RC-B")
data = fin_services.receivable_aging(tenant, customer=customer)
assert data["total"] == "100.0000"
# ---------- 对账单 ----------
def test_statement_opening_lines_closing(tenant, customer, warehouse, product):
"""期初(区间前应收未结)→ 区间内新应收(借)→ 区间内收款(贷)→ 期末平衡。"""
# 期初:30 天前应收 1000,已收 300
_recv(tenant, customer, days_ago=40, total=1000, paid=300, ref="RC-OPEN")
# 区间内:今天销售开单 500
from apps.inventory import services as inv_services
inv_services.inbound(
tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal("10"), unit_cost=Decimal("50"),
)
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 5, "unit_price": 100}],
)
sales_services.confirm_sales_bill(bill) # 应收 RC… 500
# 区间内收款 200,核销到期初应收
receipt = baker.make(
Receipt, tenant=tenant, customer=customer, bill_no="SK001",
bill_date=date.today(), amount=Decimal("200"), status="draft",
method="银行转账",
)
recv_open = Receivable.objects.get(bill_no="RC-OPEN")
fin_services.allocate_receipt(receipt, [(recv_open, Decimal("200"))])
data = fin_services.customer_statement(
tenant, customer, date_from=date.today(), date_to=date.today()
)
assert data["opening_balance"] == "700.0000" # 1000-300
assert data["closing_balance"] == "1000.0000" # 700 + 500 - 200
types = [(l["type"], l["ref"]) for l in data["lines"]]
# 应收行 ref 是应收单号(RC…,source_ref 才是销售单号)
rc_refs = [l["ref"] for l in data["lines"] if l["type"] == "receivable"]
assert len(rc_refs) == 1 and rc_refs[0].startswith("RC")
assert ("receipt", "SK001") in types
# 余额滚动校验
last = data["lines"][-1]
assert last["balance"] == "1000.0000"
# ---------- 分享链接 ----------
def test_share_link_flow(db, auth_client, anon_client, tenant, customer):
# 生成分享
resp = auth_client.post(
"/api/v1/finance/statements/share/",
{
"customer_id": customer.id,
"date_from": date.today().isoformat(),
"date_to": date.today().isoformat(),
"expires_days": 1,
},
format="json",
)
assert resp.status_code == 201, resp.content
token = resp.json()["token"]
url = resp.json()["url"]
# 匿名可访问(无需租户头/JWT)
resp2 = anon_client.get(url)
assert resp2.status_code == 200, resp2.content
body = resp2.json()
assert body["customer"]["code"] == "C001"
assert body["share"]["token"] == token
# 吊销后 404
share = StatementShare.objects.get(token=token)
share.revoked = True
share.save()
resp3 = anon_client.get(url)
assert resp3.status_code == 404
def test_share_link_expired_404(db, auth_client, anon_client, tenant, customer):
from django.utils import timezone
share = baker.make(
StatementShare, tenant=tenant, customer=customer,
date_from=date.today(), date_to=date.today(),
expires_at=timezone.now() - timedelta(hours=1),
)
resp = anon_client.get(f"/api/v1/open/statements/{share.token}/")
assert resp.status_code == 404
def test_share_list_requires_auth(db, anon_client):
resp = anon_client.get("/api/v1/finance/statements/share/")
assert resp.status_code in (401, 403)