add optimistic concurrency guards via expected scheme version id protect draft editor, pricing snapshot, remap and publish flows from stale mutations protect version creation from stale current version state keep backward compatibility with optional query guards verify 409 conflict behavior for stale clients and 200 for valid flows
372 lines
12 KiB
Python
372 lines
12 KiB
Python
from decimal import Decimal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
|
|
from app.core.config import settings
|
|
from app.repositories.audit import create_audit_event
|
|
from app.repositories.pricing import (
|
|
create_price_rule,
|
|
create_pricing_category,
|
|
delete_price_rule,
|
|
delete_pricing_category,
|
|
list_price_rules,
|
|
list_pricing_categories,
|
|
update_price_rule,
|
|
update_pricing_category,
|
|
)
|
|
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
|
|
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
|
from app.schemas.pricing import (
|
|
PriceRuleCreateRequest,
|
|
PriceRuleItem,
|
|
PriceRuleUpdateRequest,
|
|
PricingBundleResponse,
|
|
PricingCategoryCreateRequest,
|
|
PricingCategoryItem,
|
|
PricingCategoryUpdateRequest,
|
|
)
|
|
from app.security.auth import require_api_key
|
|
from app.services.draft_guard import get_current_draft_context
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def _refresh_current_draft_snapshot_if_possible(
|
|
*,
|
|
scheme_id: str,
|
|
expected_scheme_version_id: str | None = None,
|
|
) -> dict | None:
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
return await replace_scheme_version_pricing_snapshot(
|
|
scheme_id=scheme.scheme_id,
|
|
scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
|
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
|
|
async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key)):
|
|
categories = await list_pricing_categories(scheme_id)
|
|
rules = await list_price_rules(scheme_id)
|
|
|
|
return PricingBundleResponse(
|
|
categories=[
|
|
PricingCategoryItem(
|
|
pricing_category_id=row.pricing_category_id,
|
|
scheme_id=row.scheme_id,
|
|
name=row.name,
|
|
code=row.code,
|
|
created_at=row.created_at.isoformat(),
|
|
)
|
|
for row in categories
|
|
],
|
|
rules=[
|
|
PriceRuleItem(
|
|
price_rule_id=row.price_rule_id,
|
|
scheme_id=row.scheme_id,
|
|
pricing_category_id=row.pricing_category_id,
|
|
target_type=row.target_type,
|
|
target_ref=row.target_ref,
|
|
amount=str(row.amount),
|
|
currency=row.currency,
|
|
created_at=row.created_at.isoformat(),
|
|
)
|
|
for row in rules
|
|
],
|
|
)
|
|
|
|
|
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories")
|
|
async def create_pricing_category_endpoint(
|
|
scheme_id: str,
|
|
payload: PricingCategoryCreateRequest,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
pricing_category_id = await create_pricing_category(
|
|
scheme_id=scheme.scheme_id,
|
|
name=payload.name,
|
|
code=payload.code,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.category.created",
|
|
object_type="pricing_category",
|
|
object_ref=pricing_category_id,
|
|
details={
|
|
"name": payload.name,
|
|
"code": payload.code,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"pricing_category_id": pricing_category_id,
|
|
"scheme_id": scheme.scheme_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"name": payload.name,
|
|
"code": payload.code,
|
|
}
|
|
|
|
|
|
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
|
|
async def update_pricing_category_endpoint(
|
|
scheme_id: str,
|
|
pricing_category_id: str,
|
|
payload: PricingCategoryUpdateRequest,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
row = await update_pricing_category(
|
|
scheme_id=scheme.scheme_id,
|
|
pricing_category_id=pricing_category_id,
|
|
name=payload.name,
|
|
code=payload.code,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.category.updated",
|
|
object_type="pricing_category",
|
|
object_ref=pricing_category_id,
|
|
details={
|
|
"name": payload.name,
|
|
"code": payload.code,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"pricing_category_id": row.pricing_category_id,
|
|
"scheme_id": row.scheme_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"name": row.name,
|
|
"code": row.code,
|
|
}
|
|
|
|
|
|
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
|
|
async def delete_pricing_category_endpoint(
|
|
scheme_id: str,
|
|
pricing_category_id: str,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
await delete_pricing_category(
|
|
scheme_id=scheme.scheme_id,
|
|
pricing_category_id=pricing_category_id,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.category.deleted",
|
|
object_type="pricing_category",
|
|
object_ref=pricing_category_id,
|
|
details={
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"deleted": True,
|
|
"pricing_category_id": pricing_category_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
}
|
|
|
|
|
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
|
|
async def create_price_rule_endpoint(
|
|
scheme_id: str,
|
|
payload: PriceRuleCreateRequest,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
try:
|
|
amount = Decimal(payload.amount)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Некорректная сумма",
|
|
)
|
|
|
|
price_rule_id = await create_price_rule(
|
|
scheme_id=scheme.scheme_id,
|
|
pricing_category_id=payload.pricing_category_id,
|
|
target_type=payload.target_type,
|
|
target_ref=payload.target_ref,
|
|
amount=amount,
|
|
currency=payload.currency,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.rule.created",
|
|
object_type="price_rule",
|
|
object_ref=price_rule_id,
|
|
details={
|
|
"pricing_category_id": payload.pricing_category_id,
|
|
"target_type": payload.target_type,
|
|
"target_ref": payload.target_ref,
|
|
"amount": payload.amount,
|
|
"currency": payload.currency,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"price_rule_id": price_rule_id,
|
|
"scheme_id": scheme.scheme_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"pricing_category_id": payload.pricing_category_id,
|
|
"target_type": payload.target_type,
|
|
"target_ref": payload.target_ref,
|
|
"amount": payload.amount,
|
|
"currency": payload.currency,
|
|
}
|
|
|
|
|
|
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
|
|
async def update_price_rule_endpoint(
|
|
scheme_id: str,
|
|
price_rule_id: str,
|
|
payload: PriceRuleUpdateRequest,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
try:
|
|
amount = Decimal(payload.amount)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Некорректная сумма",
|
|
)
|
|
|
|
row = await update_price_rule(
|
|
scheme_id=scheme.scheme_id,
|
|
price_rule_id=price_rule_id,
|
|
pricing_category_id=payload.pricing_category_id,
|
|
target_type=payload.target_type,
|
|
target_ref=payload.target_ref,
|
|
amount=amount,
|
|
currency=payload.currency,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.rule.updated",
|
|
object_type="price_rule",
|
|
object_ref=price_rule_id,
|
|
details={
|
|
"pricing_category_id": payload.pricing_category_id,
|
|
"target_type": payload.target_type,
|
|
"target_ref": payload.target_ref,
|
|
"amount": payload.amount,
|
|
"currency": payload.currency,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"price_rule_id": row.price_rule_id,
|
|
"scheme_id": row.scheme_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"pricing_category_id": row.pricing_category_id,
|
|
"target_type": row.target_type,
|
|
"target_ref": row.target_ref,
|
|
"amount": str(row.amount),
|
|
"currency": row.currency,
|
|
}
|
|
|
|
|
|
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
|
|
async def delete_price_rule_endpoint(
|
|
scheme_id: str,
|
|
price_rule_id: str,
|
|
expected_scheme_version_id: str | None = Query(default=None),
|
|
role: str = Depends(require_api_key),
|
|
):
|
|
scheme, version = await get_current_draft_context(
|
|
scheme_id=scheme_id,
|
|
expected_scheme_version_id=expected_scheme_version_id,
|
|
)
|
|
|
|
await delete_price_rule(
|
|
scheme_id=scheme.scheme_id,
|
|
price_rule_id=price_rule_id,
|
|
)
|
|
snapshot = await _refresh_current_draft_snapshot_if_possible(
|
|
scheme_id=scheme.scheme_id,
|
|
expected_scheme_version_id=version.scheme_version_id,
|
|
)
|
|
|
|
await create_audit_event(
|
|
scheme_id=scheme.scheme_id,
|
|
event_type="pricing.rule.deleted",
|
|
object_type="price_rule",
|
|
object_ref=price_rule_id,
|
|
details={
|
|
"scheme_version_id": version.scheme_version_id,
|
|
"snapshot": snapshot,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"deleted": True,
|
|
"price_rule_id": price_rule_id,
|
|
"scheme_version_id": version.scheme_version_id,
|
|
}
|