Spaces:
Runtime error
Runtime error
Upload 32 files
Browse files- .env.example +11 -0
- .gitattributes +1 -0
- 0001_initial_schema.py +144 -0
- Dockerfile +17 -0
- README.md +11 -6
- __init__.py +1 -0
- alembic.ini +41 -0
- audit.py +50 -0
- bureau.py +22 -0
- config.py +31 -0
- context.py +45 -0
- dependencies.py +25 -0
- enums.py +37 -0
- env.py +41 -0
- hash.py +16 -0
- health.py +34 -0
- index.html +892 -0
- init_db.py +27 -0
- main.py +23 -0
- operators.py +76 -0
- requirements.txt +12 -0
- rule.py +58 -0
- rule_engine.py +48 -0
- rule_repository.py +125 -0
- rules.py +83 -0
- runtime.txt +2 -0
- scorecard.db +3 -0
- scorecard.py +96 -0
- scorecard_service.py +198 -0
- script.py.mako +25 -0
- seed_rules.py +444 -0
- session.py +25 -0
- ui.py +21 -0
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
APP_NAME=Two Wheeler Scorecard API
|
| 2 |
+
ENVIRONMENT=local
|
| 3 |
+
DATABASE_URL=postgresql+psycopg://scorecard:scorecard@postgres:5432/scorecard
|
| 4 |
+
REDIS_URL=redis://redis:6379/0
|
| 5 |
+
JWT_SECRET_KEY=change-me-in-production
|
| 6 |
+
JWT_ALGORITHM=HS256
|
| 7 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 8 |
+
DEFAULT_TENANT_ID=default
|
| 9 |
+
DEFAULT_PRODUCT_TYPE=TWO_WHEELER_LOAN
|
| 10 |
+
ENABLE_AUTH=false
|
| 11 |
+
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
scorecard.db filter=lfs diff=lfs merge=lfs -text
|
0001_initial_schema.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""initial schema
|
| 2 |
+
|
| 3 |
+
Revision ID: 0001_initial_schema
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-05-06
|
| 6 |
+
"""
|
| 7 |
+
from typing import Sequence, Union
|
| 8 |
+
|
| 9 |
+
from alembic import op
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
|
| 12 |
+
revision: str = "0001_initial_schema"
|
| 13 |
+
down_revision: Union[str, None] = None
|
| 14 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 15 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
op.create_table(
|
| 20 |
+
"rule_master",
|
| 21 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 22 |
+
sa.Column("tenant_id", sa.String(length=100), nullable=False),
|
| 23 |
+
sa.Column("product_type", sa.String(length=50), nullable=False),
|
| 24 |
+
sa.Column("dealer_id", sa.String(length=100), nullable=True),
|
| 25 |
+
sa.Column("rule_code", sa.String(length=100), nullable=False),
|
| 26 |
+
sa.Column("rule_name", sa.String(length=255), nullable=False),
|
| 27 |
+
sa.Column("rule_type", sa.String(length=50), nullable=False),
|
| 28 |
+
sa.Column("field_name", sa.String(length=100), nullable=False),
|
| 29 |
+
sa.Column("operator", sa.String(length=20), nullable=False),
|
| 30 |
+
sa.Column("field_value", sa.String(length=255), nullable=False),
|
| 31 |
+
sa.Column("score_value", sa.Numeric(10, 2), nullable=True),
|
| 32 |
+
sa.Column("score_component", sa.String(length=50), nullable=True),
|
| 33 |
+
sa.Column("decision_action", sa.String(length=50), nullable=True),
|
| 34 |
+
sa.Column("reason_code", sa.String(length=100), nullable=False),
|
| 35 |
+
sa.Column("priority", sa.Integer(), nullable=False),
|
| 36 |
+
sa.Column("version", sa.Integer(), nullable=False),
|
| 37 |
+
sa.Column("is_active", sa.Boolean(), nullable=False),
|
| 38 |
+
sa.Column("created_by", sa.String(length=100), nullable=True),
|
| 39 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 40 |
+
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
| 41 |
+
sa.PrimaryKeyConstraint("id"),
|
| 42 |
+
sa.UniqueConstraint("rule_code", "version", "tenant_id", "product_type", "dealer_id"),
|
| 43 |
+
)
|
| 44 |
+
op.create_index(op.f("ix_rule_master_dealer_id"), "rule_master", ["dealer_id"], unique=False)
|
| 45 |
+
op.create_index(op.f("ix_rule_master_id"), "rule_master", ["id"], unique=False)
|
| 46 |
+
op.create_index(op.f("ix_rule_master_is_active"), "rule_master", ["is_active"], unique=False)
|
| 47 |
+
op.create_index(op.f("ix_rule_master_product_type"), "rule_master", ["product_type"], unique=False)
|
| 48 |
+
op.create_index(op.f("ix_rule_master_rule_code"), "rule_master", ["rule_code"], unique=False)
|
| 49 |
+
op.create_index(op.f("ix_rule_master_rule_type"), "rule_master", ["rule_type"], unique=False)
|
| 50 |
+
op.create_index(op.f("ix_rule_master_tenant_id"), "rule_master", ["tenant_id"], unique=False)
|
| 51 |
+
|
| 52 |
+
op.create_table(
|
| 53 |
+
"scorecard_result",
|
| 54 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 55 |
+
sa.Column("tenant_id", sa.String(length=100), nullable=False),
|
| 56 |
+
sa.Column("product_type", sa.String(length=50), nullable=False),
|
| 57 |
+
sa.Column("lead_id", sa.String(length=100), nullable=False),
|
| 58 |
+
sa.Column("final_score", sa.Numeric(10, 2), nullable=False),
|
| 59 |
+
sa.Column("decision", sa.String(length=50), nullable=False),
|
| 60 |
+
sa.Column("underwriting_required", sa.Boolean(), nullable=False),
|
| 61 |
+
sa.Column("reject_flag", sa.Boolean(), nullable=False),
|
| 62 |
+
sa.Column("journey_action", sa.String(length=50), nullable=False),
|
| 63 |
+
sa.Column("bureau_risk", sa.String(length=50), nullable=False),
|
| 64 |
+
sa.Column("score_breakup", sa.JSON(), nullable=False),
|
| 65 |
+
sa.Column("triggered_rules", sa.JSON(), nullable=False),
|
| 66 |
+
sa.Column("reason_codes", sa.JSON(), nullable=False),
|
| 67 |
+
sa.Column("request_hash", sa.String(length=64), nullable=False),
|
| 68 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 69 |
+
sa.PrimaryKeyConstraint("id"),
|
| 70 |
+
)
|
| 71 |
+
op.create_index(op.f("ix_scorecard_result_id"), "scorecard_result", ["id"], unique=False)
|
| 72 |
+
op.create_index(op.f("ix_scorecard_result_lead_id"), "scorecard_result", ["lead_id"], unique=False)
|
| 73 |
+
op.create_index(
|
| 74 |
+
op.f("ix_scorecard_result_product_type"), "scorecard_result", ["product_type"], unique=False
|
| 75 |
+
)
|
| 76 |
+
op.create_index(
|
| 77 |
+
op.f("ix_scorecard_result_request_hash"), "scorecard_result", ["request_hash"], unique=False
|
| 78 |
+
)
|
| 79 |
+
op.create_index(op.f("ix_scorecard_result_tenant_id"), "scorecard_result", ["tenant_id"], unique=False)
|
| 80 |
+
|
| 81 |
+
op.create_table(
|
| 82 |
+
"rule_audit_log",
|
| 83 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 84 |
+
sa.Column("rule_id", sa.Integer(), nullable=True),
|
| 85 |
+
sa.Column("action", sa.String(length=50), nullable=False),
|
| 86 |
+
sa.Column("actor", sa.String(length=100), nullable=True),
|
| 87 |
+
sa.Column("before", sa.JSON(), nullable=True),
|
| 88 |
+
sa.Column("after", sa.JSON(), nullable=True),
|
| 89 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 90 |
+
sa.PrimaryKeyConstraint("id"),
|
| 91 |
+
)
|
| 92 |
+
op.create_index(op.f("ix_rule_audit_log_id"), "rule_audit_log", ["id"], unique=False)
|
| 93 |
+
op.create_index(op.f("ix_rule_audit_log_rule_id"), "rule_audit_log", ["rule_id"], unique=False)
|
| 94 |
+
|
| 95 |
+
op.create_table(
|
| 96 |
+
"scorecard_execution_audit",
|
| 97 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 98 |
+
sa.Column("lead_id", sa.String(length=100), nullable=False),
|
| 99 |
+
sa.Column("tenant_id", sa.String(length=100), nullable=False),
|
| 100 |
+
sa.Column("product_type", sa.String(length=50), nullable=False),
|
| 101 |
+
sa.Column("request_hash", sa.String(length=64), nullable=False),
|
| 102 |
+
sa.Column("matched_rules", sa.JSON(), nullable=False),
|
| 103 |
+
sa.Column("skipped_rules", sa.JSON(), nullable=False),
|
| 104 |
+
sa.Column("status", sa.String(length=50), nullable=False),
|
| 105 |
+
sa.Column("error_message", sa.Text(), nullable=True),
|
| 106 |
+
sa.Column("latency_ms", sa.Integer(), nullable=False),
|
| 107 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 108 |
+
sa.PrimaryKeyConstraint("id"),
|
| 109 |
+
)
|
| 110 |
+
op.create_index(
|
| 111 |
+
op.f("ix_scorecard_execution_audit_id"), "scorecard_execution_audit", ["id"], unique=False
|
| 112 |
+
)
|
| 113 |
+
op.create_index(
|
| 114 |
+
op.f("ix_scorecard_execution_audit_lead_id"),
|
| 115 |
+
"scorecard_execution_audit",
|
| 116 |
+
["lead_id"],
|
| 117 |
+
unique=False,
|
| 118 |
+
)
|
| 119 |
+
op.create_index(
|
| 120 |
+
op.f("ix_scorecard_execution_audit_product_type"),
|
| 121 |
+
"scorecard_execution_audit",
|
| 122 |
+
["product_type"],
|
| 123 |
+
unique=False,
|
| 124 |
+
)
|
| 125 |
+
op.create_index(
|
| 126 |
+
op.f("ix_scorecard_execution_audit_request_hash"),
|
| 127 |
+
"scorecard_execution_audit",
|
| 128 |
+
["request_hash"],
|
| 129 |
+
unique=False,
|
| 130 |
+
)
|
| 131 |
+
op.create_index(
|
| 132 |
+
op.f("ix_scorecard_execution_audit_tenant_id"),
|
| 133 |
+
"scorecard_execution_audit",
|
| 134 |
+
["tenant_id"],
|
| 135 |
+
unique=False,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def downgrade() -> None:
|
| 140 |
+
op.drop_table("scorecard_execution_audit")
|
| 141 |
+
op.drop_table("rule_audit_log")
|
| 142 |
+
op.drop_table("scorecard_result")
|
| 143 |
+
op.drop_table("rule_master")
|
| 144 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV DATABASE_URL=sqlite:///./scorecard.db
|
| 6 |
+
ENV ENABLE_AUTH=false
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
CMD ["sh", "-c", "python -m app.db.init_db && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
|
README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: SCORECARDAPI FOR STP
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Scorecard STP API
|
| 3 |
+
emoji: 🏦
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
base_path: /generate-scorecard
|
| 9 |
pinned: false
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Scorecard STP API
|
| 13 |
+
|
| 14 |
+
FastAPI scorecard API and browser testing console.
|
| 15 |
+
|
| 16 |
+
Open `/generate-scorecard` for the testing UI.
|
| 17 |
+
Open `/docs` for Swagger API documentation.
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
alembic.ini
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[alembic]
|
| 2 |
+
script_location = alembic
|
| 3 |
+
prepend_sys_path = .
|
| 4 |
+
sqlalchemy.url = sqlite:///./scorecard.db
|
| 5 |
+
|
| 6 |
+
[post_write_hooks]
|
| 7 |
+
|
| 8 |
+
[loggers]
|
| 9 |
+
keys = root,sqlalchemy,alembic
|
| 10 |
+
|
| 11 |
+
[handlers]
|
| 12 |
+
keys = console
|
| 13 |
+
|
| 14 |
+
[formatters]
|
| 15 |
+
keys = generic
|
| 16 |
+
|
| 17 |
+
[logger_root]
|
| 18 |
+
level = WARN
|
| 19 |
+
handlers = console
|
| 20 |
+
qualname =
|
| 21 |
+
|
| 22 |
+
[logger_sqlalchemy]
|
| 23 |
+
level = WARN
|
| 24 |
+
handlers =
|
| 25 |
+
qualname = sqlalchemy.engine
|
| 26 |
+
|
| 27 |
+
[logger_alembic]
|
| 28 |
+
level = INFO
|
| 29 |
+
handlers =
|
| 30 |
+
qualname = alembic
|
| 31 |
+
|
| 32 |
+
[handler_console]
|
| 33 |
+
class = StreamHandler
|
| 34 |
+
args = (sys.stderr,)
|
| 35 |
+
level = NOTSET
|
| 36 |
+
formatter = generic
|
| 37 |
+
|
| 38 |
+
[formatter_generic]
|
| 39 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 40 |
+
datefmt = %H:%M:%S
|
| 41 |
+
|
audit.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
from app.models.audit import RuleAuditLog
|
| 6 |
+
from app.models.rule import RuleMaster
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def rule_to_dict(rule: RuleMaster | None) -> dict[str, Any] | None:
|
| 10 |
+
if not rule:
|
| 11 |
+
return None
|
| 12 |
+
return {
|
| 13 |
+
"id": rule.id,
|
| 14 |
+
"tenant_id": rule.tenant_id,
|
| 15 |
+
"product_type": rule.product_type,
|
| 16 |
+
"dealer_id": rule.dealer_id,
|
| 17 |
+
"rule_code": rule.rule_code,
|
| 18 |
+
"rule_name": rule.rule_name,
|
| 19 |
+
"rule_type": rule.rule_type,
|
| 20 |
+
"field_name": rule.field_name,
|
| 21 |
+
"operator": rule.operator,
|
| 22 |
+
"field_value": rule.field_value,
|
| 23 |
+
"score_value": str(rule.score_value) if rule.score_value is not None else None,
|
| 24 |
+
"score_component": rule.score_component,
|
| 25 |
+
"decision_action": rule.decision_action,
|
| 26 |
+
"reason_code": rule.reason_code,
|
| 27 |
+
"priority": rule.priority,
|
| 28 |
+
"version": rule.version,
|
| 29 |
+
"is_active": rule.is_active,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def write_rule_audit(
|
| 34 |
+
db: Session,
|
| 35 |
+
rule_id: int | None,
|
| 36 |
+
action: str,
|
| 37 |
+
actor: str | None,
|
| 38 |
+
before: dict[str, Any] | None,
|
| 39 |
+
after: dict[str, Any] | None,
|
| 40 |
+
) -> None:
|
| 41 |
+
db.add(
|
| 42 |
+
RuleAuditLog(
|
| 43 |
+
rule_id=rule_id,
|
| 44 |
+
action=action,
|
| 45 |
+
actor=actor,
|
| 46 |
+
before=before,
|
| 47 |
+
after=after,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
bureau.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.enums import BureauRisk
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class BureauRiskClassifier:
|
| 5 |
+
def classify(self, context: dict) -> BureauRisk:
|
| 6 |
+
max_dpd = context.get("maxDPD") or 0
|
| 7 |
+
dpd60 = context.get("dpd60Count") or 0
|
| 8 |
+
dpd90 = context.get("dpd90Count") or 0
|
| 9 |
+
enquiries = context.get("creditEnquiries6M") or 0
|
| 10 |
+
unsecured = context.get("activeUnsecuredLoans") or 0
|
| 11 |
+
cibil = context.get("cibilScore") or 0
|
| 12 |
+
overdue = context.get("overdueAmount") or 0
|
| 13 |
+
dpd30 = context.get("dpd30Count") or 0
|
| 14 |
+
|
| 15 |
+
if dpd60 > 0 or dpd90 > 0 or max_dpd > 90 or enquiries > 5 or unsecured > 2:
|
| 16 |
+
return BureauRisk.HIGH
|
| 17 |
+
if dpd30 > 0 or max_dpd > 0 or enquiries > 2 or 700 <= cibil <= 750:
|
| 18 |
+
return BureauRisk.MEDIUM
|
| 19 |
+
if cibil > 750 and overdue <= 0 and enquiries <= 2:
|
| 20 |
+
return BureauRisk.LOW
|
| 21 |
+
return BureauRisk.MEDIUM
|
| 22 |
+
|
config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
from pydantic import Field, field_validator
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
app_name: str = "Two Wheeler Scorecard API"
|
| 9 |
+
environment: str = "local"
|
| 10 |
+
database_url: str = "sqlite:///./scorecard.db"
|
| 11 |
+
redis_url: str | None = None
|
| 12 |
+
jwt_secret_key: str = "change-me"
|
| 13 |
+
jwt_algorithm: str = "HS256"
|
| 14 |
+
access_token_expire_minutes: int = 60
|
| 15 |
+
default_tenant_id: str = "default"
|
| 16 |
+
default_product_type: str = "TWO_WHEELER_LOAN"
|
| 17 |
+
enable_auth: bool = Field(default=False)
|
| 18 |
+
|
| 19 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
| 20 |
+
|
| 21 |
+
@field_validator("database_url")
|
| 22 |
+
@classmethod
|
| 23 |
+
def normalize_database_url(cls, value: str) -> str:
|
| 24 |
+
if value.startswith("postgresql://"):
|
| 25 |
+
return value.replace("postgresql://", "postgresql+psycopg://", 1)
|
| 26 |
+
return value
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@lru_cache
|
| 30 |
+
def get_settings() -> Settings:
|
| 31 |
+
return Settings()
|
context.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from app.schemas.scorecard import ScorecardRequest
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
ALIASES = {
|
| 9 |
+
"leadId": "leadId",
|
| 10 |
+
"dealerId": "dealerId",
|
| 11 |
+
"enquiries6M": "creditEnquiries6M",
|
| 12 |
+
"creditEnquiries6M": "creditEnquiries6M",
|
| 13 |
+
"enquiries12M": "creditEnquiries12M",
|
| 14 |
+
"creditEnquiries12M": "creditEnquiries12M",
|
| 15 |
+
"fraudFlag": "fraudFlag",
|
| 16 |
+
"writtenOffAccounts": "writtenOffAccounts",
|
| 17 |
+
"settledAccounts": "settledAccounts",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _dump(model: BaseModel) -> dict[str, Any]:
|
| 22 |
+
return model.model_dump(by_alias=False)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_evaluation_context(request: ScorecardRequest) -> dict[str, Any]:
|
| 26 |
+
context = {}
|
| 27 |
+
context.update(_dump(request.application))
|
| 28 |
+
context.update(_dump(request.bureau))
|
| 29 |
+
context["tenantId"] = request.tenantId
|
| 30 |
+
context["productType"] = request.productType
|
| 31 |
+
for alias, target in ALIASES.items():
|
| 32 |
+
if target in context:
|
| 33 |
+
context[alias] = context[target]
|
| 34 |
+
context["kycVerified"] = bool(context.get("panVerified")) and bool(context.get("aadhaarVerified"))
|
| 35 |
+
context["stableEmployment"] = context.get("employmentType") in {"SALARIED", "SELF_EMPLOYED"}
|
| 36 |
+
context["noDpd"] = (
|
| 37 |
+
(context.get("maxDPD") or 0) <= 0
|
| 38 |
+
and (context.get("dpd30Count") or 0) <= 0
|
| 39 |
+
and (context.get("dpd60Count") or 0) <= 0
|
| 40 |
+
and (context.get("dpd90Count") or 0) <= 0
|
| 41 |
+
)
|
| 42 |
+
context["noFraud"] = not bool(context.get("fraudFlag") or context.get("blacklistMatch"))
|
| 43 |
+
context["noOverdue"] = (context.get("overdueAmount") or 0) <= 0
|
| 44 |
+
return context
|
| 45 |
+
|
dependencies.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
|
| 5 |
+
from app.core.config import get_settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
bearer = HTTPBearer(auto_error=False)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def require_auth(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)) -> dict:
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
if not settings.enable_auth:
|
| 14 |
+
return {"sub": "local-dev"}
|
| 15 |
+
if not credentials:
|
| 16 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
| 17 |
+
try:
|
| 18 |
+
return jwt.decode(
|
| 19 |
+
credentials.credentials,
|
| 20 |
+
settings.jwt_secret_key,
|
| 21 |
+
algorithms=[settings.jwt_algorithm],
|
| 22 |
+
)
|
| 23 |
+
except JWTError as exc:
|
| 24 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
| 25 |
+
|
enums.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import StrEnum
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class RuleType(StrEnum):
|
| 5 |
+
SCORE = "SCORE"
|
| 6 |
+
HARD_REJECT = "HARD_REJECT"
|
| 7 |
+
NON_STP = "NON_STP"
|
| 8 |
+
STP_ELIGIBILITY = "STP_ELIGIBILITY"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DecisionAction(StrEnum):
|
| 12 |
+
STP = "STP"
|
| 13 |
+
CONDITIONAL_STP = "CONDITIONAL_STP"
|
| 14 |
+
NON_STP = "NON_STP"
|
| 15 |
+
UW = "UW"
|
| 16 |
+
REJECT = "REJECT"
|
| 17 |
+
SCORE = "SCORE"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ScorecardDecision(StrEnum):
|
| 21 |
+
STP = "STP"
|
| 22 |
+
CONDITIONAL_STP = "CONDITIONAL_STP"
|
| 23 |
+
NON_STP = "NON_STP"
|
| 24 |
+
REJECT = "REJECT"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class JourneyAction(StrEnum):
|
| 28 |
+
SKIP_UNDERWRITING = "SKIP_UNDERWRITING"
|
| 29 |
+
SEND_TO_UNDERWRITING = "SEND_TO_UNDERWRITING"
|
| 30 |
+
REJECT_APPLICATION = "REJECT_APPLICATION"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class BureauRisk(StrEnum):
|
| 34 |
+
LOW = "LOW"
|
| 35 |
+
MEDIUM = "MEDIUM"
|
| 36 |
+
HIGH = "HIGH"
|
| 37 |
+
|
env.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from alembic import context
|
| 4 |
+
from sqlalchemy import engine_from_config, pool
|
| 5 |
+
|
| 6 |
+
from app.core.config import get_settings
|
| 7 |
+
from app.db.session import Base
|
| 8 |
+
from app.models import audit, rule, scorecard # noqa: F401
|
| 9 |
+
|
| 10 |
+
config = context.config
|
| 11 |
+
if config.config_file_name is not None:
|
| 12 |
+
fileConfig(config.config_file_name)
|
| 13 |
+
|
| 14 |
+
target_metadata = Base.metadata
|
| 15 |
+
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def run_migrations_offline() -> None:
|
| 19 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 20 |
+
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
| 21 |
+
with context.begin_transaction():
|
| 22 |
+
context.run_migrations()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_migrations_online() -> None:
|
| 26 |
+
connectable = engine_from_config(
|
| 27 |
+
config.get_section(config.config_ini_section, {}),
|
| 28 |
+
prefix="sqlalchemy.",
|
| 29 |
+
poolclass=pool.NullPool,
|
| 30 |
+
)
|
| 31 |
+
with connectable.connect() as connection:
|
| 32 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 33 |
+
with context.begin_transaction():
|
| 34 |
+
context.run_migrations()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if context.is_offline_mode():
|
| 38 |
+
run_migrations_offline()
|
| 39 |
+
else:
|
| 40 |
+
run_migrations_online()
|
| 41 |
+
|
hash.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _json_default(value: Any) -> str:
|
| 8 |
+
if isinstance(value, Decimal):
|
| 9 |
+
return str(value)
|
| 10 |
+
return str(value)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def stable_payload_hash(payload: dict[str, Any]) -> str:
|
| 14 |
+
serialized = json.dumps(payload, sort_keys=True, default=_json_default, separators=(",", ":"))
|
| 15 |
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
| 16 |
+
|
health.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from redis import Redis
|
| 3 |
+
from sqlalchemy import text
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.core.config import get_settings
|
| 7 |
+
from app.db.session import get_db
|
| 8 |
+
from app.schemas.scorecard import HealthResponse
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["Health"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/health", response_model=HealthResponse)
|
| 14 |
+
def health(db: Session = Depends(get_db)) -> HealthResponse:
|
| 15 |
+
database = "ok"
|
| 16 |
+
redis_status = "disabled"
|
| 17 |
+
details = {}
|
| 18 |
+
try:
|
| 19 |
+
db.execute(text("SELECT 1"))
|
| 20 |
+
except Exception as exc:
|
| 21 |
+
database = "error"
|
| 22 |
+
details["database_error"] = str(exc)
|
| 23 |
+
|
| 24 |
+
settings = get_settings()
|
| 25 |
+
if settings.redis_url:
|
| 26 |
+
try:
|
| 27 |
+
Redis.from_url(settings.redis_url).ping()
|
| 28 |
+
redis_status = "ok"
|
| 29 |
+
except Exception as exc:
|
| 30 |
+
redis_status = "error"
|
| 31 |
+
details["redis_error"] = str(exc)
|
| 32 |
+
status = "ok" if database == "ok" and redis_status in {"ok", "disabled"} else "degraded"
|
| 33 |
+
return HealthResponse(status=status, database=database, redis=redis_status, details=details)
|
| 34 |
+
|
index.html
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Two Wheeler Scorecard Console</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #f4f7fb;
|
| 10 |
+
--panel: #ffffff;
|
| 11 |
+
--line: #d9e2ec;
|
| 12 |
+
--text: #172033;
|
| 13 |
+
--muted: #637083;
|
| 14 |
+
--accent: #116d6e;
|
| 15 |
+
--accent-2: #204b8c;
|
| 16 |
+
--danger: #b42318;
|
| 17 |
+
--warn: #a15c07;
|
| 18 |
+
--ok: #16794c;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
* {
|
| 22 |
+
box-sizing: border-box;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
background: var(--bg);
|
| 28 |
+
color: var(--text);
|
| 29 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
header {
|
| 33 |
+
background: #0d1b2a;
|
| 34 |
+
color: white;
|
| 35 |
+
padding: 18px 24px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
header h1 {
|
| 39 |
+
margin: 0;
|
| 40 |
+
font-size: 22px;
|
| 41 |
+
font-weight: 700;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
header p {
|
| 45 |
+
margin: 6px 0 0;
|
| 46 |
+
color: #cbd5e1;
|
| 47 |
+
font-size: 14px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
main {
|
| 51 |
+
display: grid;
|
| 52 |
+
grid-template-columns: minmax(360px, 1.05fr) minmax(360px, 0.95fr);
|
| 53 |
+
gap: 16px;
|
| 54 |
+
padding: 16px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
section {
|
| 58 |
+
background: var(--panel);
|
| 59 |
+
border: 1px solid var(--line);
|
| 60 |
+
border-radius: 8px;
|
| 61 |
+
min-width: 0;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.section-head {
|
| 65 |
+
display: flex;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
gap: 12px;
|
| 68 |
+
align-items: center;
|
| 69 |
+
padding: 14px 16px;
|
| 70 |
+
border-bottom: 1px solid var(--line);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
h2 {
|
| 74 |
+
margin: 0;
|
| 75 |
+
font-size: 17px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.body {
|
| 79 |
+
padding: 16px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.row {
|
| 83 |
+
display: grid;
|
| 84 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 85 |
+
gap: 10px;
|
| 86 |
+
margin-bottom: 10px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.api-row {
|
| 90 |
+
display: grid;
|
| 91 |
+
grid-template-columns: minmax(220px, 1.2fr) minmax(170px, 0.8fr) minmax(150px, 0.7fr);
|
| 92 |
+
gap: 10px;
|
| 93 |
+
margin-bottom: 10px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
label {
|
| 97 |
+
display: block;
|
| 98 |
+
color: var(--muted);
|
| 99 |
+
font-size: 12px;
|
| 100 |
+
margin-bottom: 4px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
input,
|
| 104 |
+
select,
|
| 105 |
+
textarea {
|
| 106 |
+
width: 100%;
|
| 107 |
+
border: 1px solid var(--line);
|
| 108 |
+
border-radius: 6px;
|
| 109 |
+
padding: 9px 10px;
|
| 110 |
+
background: white;
|
| 111 |
+
color: var(--text);
|
| 112 |
+
font-size: 14px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
textarea {
|
| 116 |
+
min-height: 320px;
|
| 117 |
+
resize: vertical;
|
| 118 |
+
font-family: Consolas, monospace;
|
| 119 |
+
line-height: 1.35;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
button {
|
| 123 |
+
border: 0;
|
| 124 |
+
border-radius: 6px;
|
| 125 |
+
background: var(--accent);
|
| 126 |
+
color: white;
|
| 127 |
+
cursor: pointer;
|
| 128 |
+
font-weight: 700;
|
| 129 |
+
padding: 10px 13px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
button.secondary {
|
| 133 |
+
background: var(--accent-2);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
button.ghost {
|
| 137 |
+
background: #e8eef6;
|
| 138 |
+
color: var(--text);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
button.danger {
|
| 142 |
+
background: var(--danger);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.actions {
|
| 146 |
+
display: flex;
|
| 147 |
+
flex-wrap: wrap;
|
| 148 |
+
gap: 8px;
|
| 149 |
+
margin: 12px 0;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.decision {
|
| 153 |
+
display: grid;
|
| 154 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 155 |
+
gap: 10px;
|
| 156 |
+
margin-top: 12px;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.metric {
|
| 160 |
+
border: 1px solid var(--line);
|
| 161 |
+
border-radius: 8px;
|
| 162 |
+
padding: 12px;
|
| 163 |
+
background: #fbfdff;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.metric span {
|
| 167 |
+
color: var(--muted);
|
| 168 |
+
display: block;
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.metric strong {
|
| 173 |
+
display: block;
|
| 174 |
+
font-size: 18px;
|
| 175 |
+
margin-top: 5px;
|
| 176 |
+
overflow-wrap: anywhere;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.STP {
|
| 180 |
+
color: var(--ok);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.CONDITIONAL_STP {
|
| 184 |
+
color: var(--warn);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.NON_STP,
|
| 188 |
+
.REJECT {
|
| 189 |
+
color: var(--danger);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
pre {
|
| 193 |
+
background: #101827;
|
| 194 |
+
color: #dbeafe;
|
| 195 |
+
border-radius: 8px;
|
| 196 |
+
padding: 12px;
|
| 197 |
+
overflow: auto;
|
| 198 |
+
max-height: 360px;
|
| 199 |
+
font-size: 13px;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
table {
|
| 203 |
+
width: 100%;
|
| 204 |
+
border-collapse: collapse;
|
| 205 |
+
font-size: 13px;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
th,
|
| 209 |
+
td {
|
| 210 |
+
border-bottom: 1px solid var(--line);
|
| 211 |
+
padding: 9px 7px;
|
| 212 |
+
text-align: left;
|
| 213 |
+
vertical-align: top;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
th {
|
| 217 |
+
color: var(--muted);
|
| 218 |
+
font-weight: 700;
|
| 219 |
+
background: #f8fafc;
|
| 220 |
+
position: sticky;
|
| 221 |
+
top: 0;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.table-wrap {
|
| 225 |
+
border: 1px solid var(--line);
|
| 226 |
+
border-radius: 8px;
|
| 227 |
+
overflow: auto;
|
| 228 |
+
max-height: 390px;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.status {
|
| 232 |
+
min-height: 20px;
|
| 233 |
+
color: var(--muted);
|
| 234 |
+
font-size: 13px;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.status.ok {
|
| 238 |
+
color: var(--ok);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.status.error {
|
| 242 |
+
color: var(--danger);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.hint {
|
| 246 |
+
color: var(--muted);
|
| 247 |
+
font-size: 12px;
|
| 248 |
+
line-height: 1.45;
|
| 249 |
+
margin: 4px 0 12px;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.request-line {
|
| 253 |
+
background: #eef6f6;
|
| 254 |
+
border: 1px solid var(--line);
|
| 255 |
+
border-radius: 6px;
|
| 256 |
+
color: var(--accent);
|
| 257 |
+
font-family: Consolas, monospace;
|
| 258 |
+
font-size: 13px;
|
| 259 |
+
margin: 8px 0 12px;
|
| 260 |
+
overflow-wrap: anywhere;
|
| 261 |
+
padding: 9px 10px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.edit-banner {
|
| 265 |
+
display: none;
|
| 266 |
+
align-items: center;
|
| 267 |
+
justify-content: space-between;
|
| 268 |
+
gap: 10px;
|
| 269 |
+
border: 1px solid #f2c94c;
|
| 270 |
+
background: #fff8db;
|
| 271 |
+
border-radius: 8px;
|
| 272 |
+
padding: 10px 12px;
|
| 273 |
+
margin-bottom: 12px;
|
| 274 |
+
color: #6b4e00;
|
| 275 |
+
font-size: 13px;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.rule-form {
|
| 279 |
+
display: grid;
|
| 280 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 281 |
+
gap: 10px;
|
| 282 |
+
margin-bottom: 12px;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.wide {
|
| 286 |
+
grid-column: span 2;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
@media (max-width: 980px) {
|
| 290 |
+
main,
|
| 291 |
+
.api-row,
|
| 292 |
+
.row,
|
| 293 |
+
.decision,
|
| 294 |
+
.rule-form {
|
| 295 |
+
grid-template-columns: 1fr;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.wide {
|
| 299 |
+
grid-column: auto;
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
</style>
|
| 303 |
+
</head>
|
| 304 |
+
<body>
|
| 305 |
+
<header>
|
| 306 |
+
<h1>Two Wheeler Loan STP Scorecard Console</h1>
|
| 307 |
+
<p>Configure rules, simulate decisions, and generate persisted scorecards from one screen.</p>
|
| 308 |
+
</header>
|
| 309 |
+
|
| 310 |
+
<main>
|
| 311 |
+
<section>
|
| 312 |
+
<div class="section-head">
|
| 313 |
+
<h2>Scorecard Tester</h2>
|
| 314 |
+
<button class="ghost" onclick="loadSample()">Load Sample</button>
|
| 315 |
+
</div>
|
| 316 |
+
<div class="body">
|
| 317 |
+
<div class="row">
|
| 318 |
+
<div>
|
| 319 |
+
<label>Tenant</label>
|
| 320 |
+
<input id="tenantId" value="default" />
|
| 321 |
+
</div>
|
| 322 |
+
<div>
|
| 323 |
+
<label>Product</label>
|
| 324 |
+
<select id="productType" onchange="syncProductChange()">
|
| 325 |
+
<option value="TWO_WHEELER_LOAN">Two Wheeler Loan</option>
|
| 326 |
+
<option value="NEW_CAR_LOAN">New Car Loan</option>
|
| 327 |
+
<option value="USED_CAR_LOAN">Used Car Loan</option>
|
| 328 |
+
<option value="NEW_COMMERCIAL_VEHICLE_LOAN">New Commercial Vehicle</option>
|
| 329 |
+
<option value="USED_COMMERCIAL_VEHICLE_LOAN">Used Commercial Vehicle</option>
|
| 330 |
+
<option value="NEW_TRACTOR_LOAN">New Tractor Loan</option>
|
| 331 |
+
<option value="USED_TRACTOR_LOAN">Used Tractor Loan</option>
|
| 332 |
+
</select>
|
| 333 |
+
</div>
|
| 334 |
+
<div>
|
| 335 |
+
<label>Lead ID</label>
|
| 336 |
+
<input id="leadId" value="TW10001" />
|
| 337 |
+
</div>
|
| 338 |
+
<div>
|
| 339 |
+
<label>Journey Trigger</label>
|
| 340 |
+
<select id="dueDiligenceStatus">
|
| 341 |
+
<option value="APPROVED">Due Diligence Approved</option>
|
| 342 |
+
<option value="PENDING">Due Diligence Pending</option>
|
| 343 |
+
<option value="REJECTED">Due Diligence Rejected</option>
|
| 344 |
+
</select>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<div class="api-row">
|
| 349 |
+
<div>
|
| 350 |
+
<label>API Base URL</label>
|
| 351 |
+
<input id="apiBaseUrl" />
|
| 352 |
+
</div>
|
| 353 |
+
<div>
|
| 354 |
+
<label>Endpoint</label>
|
| 355 |
+
<select id="mode" onchange="refreshRequestPreview()">
|
| 356 |
+
<option value="simulate-scorecard">POST /simulate-scorecard</option>
|
| 357 |
+
<option value="generate-scorecard">POST /generate-scorecard</option>
|
| 358 |
+
</select>
|
| 359 |
+
</div>
|
| 360 |
+
<div>
|
| 361 |
+
<label>Method</label>
|
| 362 |
+
<input id="method" value="POST" disabled />
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
<div class="hint">
|
| 366 |
+
Use simulate for testing only. Use generate after due diligence approval when you want the scorecard result saved against the lead.
|
| 367 |
+
</div>
|
| 368 |
+
<div class="request-line" id="requestPreview"></div>
|
| 369 |
+
|
| 370 |
+
<label>Request JSON</label>
|
| 371 |
+
<textarea id="payload"></textarea>
|
| 372 |
+
|
| 373 |
+
<div class="actions">
|
| 374 |
+
<button onclick="runScorecard()">Send API Request</button>
|
| 375 |
+
<button class="secondary" onclick="triggerAfterDueDiligence()">Trigger After Due Diligence</button>
|
| 376 |
+
<button class="secondary" onclick="getSavedResult()">Get Saved Result</button>
|
| 377 |
+
<button class="ghost" onclick="openDocs()">Swagger Docs</button>
|
| 378 |
+
<button class="ghost" onclick="copyCurl()">Copy cURL</button>
|
| 379 |
+
</div>
|
| 380 |
+
<div class="status" id="scoreStatus"></div>
|
| 381 |
+
|
| 382 |
+
<div class="decision">
|
| 383 |
+
<div class="metric">
|
| 384 |
+
<span>Decision</span>
|
| 385 |
+
<strong id="decision">-</strong>
|
| 386 |
+
</div>
|
| 387 |
+
<div class="metric">
|
| 388 |
+
<span>Final Score</span>
|
| 389 |
+
<strong id="finalScore">-</strong>
|
| 390 |
+
</div>
|
| 391 |
+
<div class="metric">
|
| 392 |
+
<span>Journey Action</span>
|
| 393 |
+
<strong id="journeyAction">-</strong>
|
| 394 |
+
</div>
|
| 395 |
+
<div class="metric">
|
| 396 |
+
<span>Bureau Risk</span>
|
| 397 |
+
<strong id="bureauRisk">-</strong>
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
|
| 401 |
+
<pre id="scoreOutput">{}</pre>
|
| 402 |
+
<label>Request Preview</label>
|
| 403 |
+
<pre id="requestOutput">{}</pre>
|
| 404 |
+
</div>
|
| 405 |
+
</section>
|
| 406 |
+
|
| 407 |
+
<section>
|
| 408 |
+
<div class="section-head">
|
| 409 |
+
<h2>Rule Configuration</h2>
|
| 410 |
+
<button class="ghost" onclick="loadRules()">Refresh Rules</button>
|
| 411 |
+
</div>
|
| 412 |
+
<div class="body">
|
| 413 |
+
<div class="rule-form">
|
| 414 |
+
<input id="editingRuleId" type="hidden" />
|
| 415 |
+
<div>
|
| 416 |
+
<label>Rule Code</label>
|
| 417 |
+
<input id="ruleCode" placeholder="CIBIL_SUPER_HIGH" />
|
| 418 |
+
</div>
|
| 419 |
+
<div class="wide">
|
| 420 |
+
<label>Rule Name</label>
|
| 421 |
+
<input id="ruleName" placeholder="CIBIL score above 800" />
|
| 422 |
+
</div>
|
| 423 |
+
<div>
|
| 424 |
+
<label>Rule Type</label>
|
| 425 |
+
<select id="ruleType">
|
| 426 |
+
<option>SCORE</option>
|
| 427 |
+
<option>HARD_REJECT</option>
|
| 428 |
+
<option>NON_STP</option>
|
| 429 |
+
<option>STP_ELIGIBILITY</option>
|
| 430 |
+
</select>
|
| 431 |
+
</div>
|
| 432 |
+
<div>
|
| 433 |
+
<label>Field Name</label>
|
| 434 |
+
<input id="fieldName" placeholder="cibilScore" />
|
| 435 |
+
</div>
|
| 436 |
+
<div>
|
| 437 |
+
<label>Operator</label>
|
| 438 |
+
<select id="operator">
|
| 439 |
+
<option>>=</option>
|
| 440 |
+
<option>></option>
|
| 441 |
+
<option><=</option>
|
| 442 |
+
<option><</option>
|
| 443 |
+
<option>=</option>
|
| 444 |
+
<option>!=</option>
|
| 445 |
+
<option>BETWEEN</option>
|
| 446 |
+
<option>IN</option>
|
| 447 |
+
<option>NOT_IN</option>
|
| 448 |
+
</select>
|
| 449 |
+
</div>
|
| 450 |
+
<div>
|
| 451 |
+
<label>Value</label>
|
| 452 |
+
<input id="fieldValue" placeholder="750 or 700,749" />
|
| 453 |
+
</div>
|
| 454 |
+
<div>
|
| 455 |
+
<label>Score Value</label>
|
| 456 |
+
<input id="scoreValue" placeholder="10" />
|
| 457 |
+
</div>
|
| 458 |
+
<div>
|
| 459 |
+
<label>Score Component</label>
|
| 460 |
+
<input id="scoreComponent" placeholder="cibil" />
|
| 461 |
+
</div>
|
| 462 |
+
<div>
|
| 463 |
+
<label>Decision Action</label>
|
| 464 |
+
<select id="decisionAction">
|
| 465 |
+
<option value="">None</option>
|
| 466 |
+
<option>SCORE</option>
|
| 467 |
+
<option>STP</option>
|
| 468 |
+
<option>UW</option>
|
| 469 |
+
<option>REJECT</option>
|
| 470 |
+
<option>NON_STP</option>
|
| 471 |
+
</select>
|
| 472 |
+
</div>
|
| 473 |
+
<div>
|
| 474 |
+
<label>Reason Code</label>
|
| 475 |
+
<input id="reasonCode" placeholder="HIGH_CIBIL" />
|
| 476 |
+
</div>
|
| 477 |
+
<div>
|
| 478 |
+
<label>Priority</label>
|
| 479 |
+
<input id="priority" value="100" />
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
<div class="edit-banner" id="editBanner">
|
| 484 |
+
<span id="editText">Editing rule</span>
|
| 485 |
+
<button class="ghost" onclick="cancelEdit()">Cancel Edit</button>
|
| 486 |
+
</div>
|
| 487 |
+
|
| 488 |
+
<div class="actions">
|
| 489 |
+
<button id="saveRuleButton" onclick="saveRule()">Create Rule</button>
|
| 490 |
+
<button class="ghost" onclick="clearRuleForm()">Clear</button>
|
| 491 |
+
<button class="secondary" onclick="exportRules()">Export Product Rules</button>
|
| 492 |
+
</div>
|
| 493 |
+
<div class="status" id="ruleStatus"></div>
|
| 494 |
+
|
| 495 |
+
<div class="table-wrap">
|
| 496 |
+
<table>
|
| 497 |
+
<thead>
|
| 498 |
+
<tr>
|
| 499 |
+
<th>ID</th>
|
| 500 |
+
<th>Code</th>
|
| 501 |
+
<th>Type</th>
|
| 502 |
+
<th>Condition</th>
|
| 503 |
+
<th>Score/Action</th>
|
| 504 |
+
<th>Status</th>
|
| 505 |
+
<th>Actions</th>
|
| 506 |
+
</tr>
|
| 507 |
+
</thead>
|
| 508 |
+
<tbody id="rulesTable"></tbody>
|
| 509 |
+
</table>
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
</section>
|
| 513 |
+
</main>
|
| 514 |
+
|
| 515 |
+
<script>
|
| 516 |
+
const samplePayload = {
|
| 517 |
+
tenantId: "default",
|
| 518 |
+
productType: "TWO_WHEELER_LOAN",
|
| 519 |
+
application: {
|
| 520 |
+
leadId: "TW10001",
|
| 521 |
+
customerName: "ABC",
|
| 522 |
+
mobile: "9999999999",
|
| 523 |
+
employmentType: "SALARIED",
|
| 524 |
+
monthlyIncome: 45000,
|
| 525 |
+
existingEMI: 8000,
|
| 526 |
+
foir: 32,
|
| 527 |
+
loanAmount: 95000,
|
| 528 |
+
assetCost: 125000,
|
| 529 |
+
ltv: 76,
|
| 530 |
+
tenure: 36,
|
| 531 |
+
dealerId: "DL101",
|
| 532 |
+
state: "Karnataka",
|
| 533 |
+
city: "Bangalore",
|
| 534 |
+
negativeArea: false,
|
| 535 |
+
fraudFlag: false,
|
| 536 |
+
faceMatchScore: 92,
|
| 537 |
+
panVerified: true,
|
| 538 |
+
aadhaarVerified: true,
|
| 539 |
+
bankingSurrogate: true,
|
| 540 |
+
coApplicant: false
|
| 541 |
+
},
|
| 542 |
+
bureau: {
|
| 543 |
+
cibilScore: 753,
|
| 544 |
+
creditVintageMonths: 123,
|
| 545 |
+
activeAccounts: 2,
|
| 546 |
+
closedAccounts: 47,
|
| 547 |
+
overdueAmount: 0,
|
| 548 |
+
writtenOffAccounts: 0,
|
| 549 |
+
settledAccounts: 0,
|
| 550 |
+
active90PlusDpd: false,
|
| 551 |
+
maxDPD: 0,
|
| 552 |
+
dpd30Count: 0,
|
| 553 |
+
dpd60Count: 0,
|
| 554 |
+
dpd90Count: 0,
|
| 555 |
+
creditEnquiries6M: 2,
|
| 556 |
+
creditEnquiries12M: 4,
|
| 557 |
+
activeUnsecuredLoans: 1,
|
| 558 |
+
activeSecuredLoans: 1,
|
| 559 |
+
jointAccounts: 0,
|
| 560 |
+
creditUtilization: 2
|
| 561 |
+
}
|
| 562 |
+
};
|
| 563 |
+
|
| 564 |
+
function setStatus(id, message) {
|
| 565 |
+
const element = document.getElementById(id);
|
| 566 |
+
element.textContent = message;
|
| 567 |
+
element.className = "status";
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
function setStatusState(id, message, state) {
|
| 571 |
+
const element = document.getElementById(id);
|
| 572 |
+
element.textContent = message;
|
| 573 |
+
element.className = `status ${state || ""}`;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
function loadSample() {
|
| 577 |
+
document.getElementById("payload").value = JSON.stringify(samplePayload, null, 2);
|
| 578 |
+
document.getElementById("leadId").value = samplePayload.application.leadId;
|
| 579 |
+
refreshRequestPreview();
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
function syncProductChange() {
|
| 583 |
+
try {
|
| 584 |
+
const payload = JSON.parse(document.getElementById("payload").value);
|
| 585 |
+
payload.productType = document.getElementById("productType").value;
|
| 586 |
+
document.getElementById("payload").value = JSON.stringify(payload, null, 2);
|
| 587 |
+
} catch (_) {
|
| 588 |
+
// Keep user-entered JSON untouched if it is mid-edit.
|
| 589 |
+
}
|
| 590 |
+
cancelEdit();
|
| 591 |
+
loadRules();
|
| 592 |
+
refreshRequestPreview();
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
function syncHeaderFields(payload) {
|
| 596 |
+
payload.tenantId = document.getElementById("tenantId").value || "default";
|
| 597 |
+
payload.productType = document.getElementById("productType").value || "TWO_WHEELER_LOAN";
|
| 598 |
+
payload.application.leadId = document.getElementById("leadId").value || payload.application.leadId;
|
| 599 |
+
return payload;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
function getBaseUrl() {
|
| 603 |
+
return (document.getElementById("apiBaseUrl").value || window.location.origin).replace(/\/$/, "");
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
function buildUrl(path) {
|
| 607 |
+
return `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
function getScorecardPath() {
|
| 611 |
+
return `/${document.getElementById("mode").value}`;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function refreshRequestPreview() {
|
| 615 |
+
const path = getScorecardPath();
|
| 616 |
+
document.getElementById("requestPreview").textContent = `POST ${buildUrl(path)}`;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
async function api(path, options = {}) {
|
| 620 |
+
const response = await fetch(buildUrl(path), {
|
| 621 |
+
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
| 622 |
+
...options
|
| 623 |
+
});
|
| 624 |
+
const text = await response.text();
|
| 625 |
+
let body = null;
|
| 626 |
+
try {
|
| 627 |
+
body = text ? JSON.parse(text) : null;
|
| 628 |
+
} catch (_) {
|
| 629 |
+
body = text;
|
| 630 |
+
}
|
| 631 |
+
if (!response.ok) {
|
| 632 |
+
throw new Error(body?.detail || body || response.statusText);
|
| 633 |
+
}
|
| 634 |
+
return { body, status: response.status, statusText: response.statusText };
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
function readPayload() {
|
| 638 |
+
const payload = syncHeaderFields(JSON.parse(document.getElementById("payload").value));
|
| 639 |
+
document.getElementById("payload").value = JSON.stringify(payload, null, 2);
|
| 640 |
+
return payload;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
function renderRequest(path, payload) {
|
| 644 |
+
const request = {
|
| 645 |
+
method: "POST",
|
| 646 |
+
url: buildUrl(path),
|
| 647 |
+
headers: { "Content-Type": "application/json" },
|
| 648 |
+
body: payload
|
| 649 |
+
};
|
| 650 |
+
document.getElementById("requestOutput").textContent = JSON.stringify(request, null, 2);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
function renderScore(body) {
|
| 654 |
+
document.getElementById("scoreOutput").textContent = JSON.stringify(body, null, 2);
|
| 655 |
+
const decision = document.getElementById("decision");
|
| 656 |
+
decision.textContent = body.decision || "-";
|
| 657 |
+
decision.className = body.decision || "";
|
| 658 |
+
document.getElementById("finalScore").textContent = body.finalScore ?? "-";
|
| 659 |
+
document.getElementById("journeyAction").textContent = body.journeyAction || "-";
|
| 660 |
+
document.getElementById("bureauRisk").textContent = body.bureauRisk || "-";
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
async function runScorecard() {
|
| 664 |
+
try {
|
| 665 |
+
setStatus("scoreStatus", "Sending API request...");
|
| 666 |
+
const payload = readPayload();
|
| 667 |
+
const path = getScorecardPath();
|
| 668 |
+
renderRequest(path, payload);
|
| 669 |
+
const result = await api(path, { method: "POST", body: JSON.stringify(payload) });
|
| 670 |
+
renderScore(result.body);
|
| 671 |
+
setStatusState("scoreStatus", `HTTP ${result.status} ${result.statusText} - scorecard completed.`, "ok");
|
| 672 |
+
} catch (error) {
|
| 673 |
+
setStatusState("scoreStatus", `Error: ${error.message}`, "error");
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
async function triggerAfterDueDiligence() {
|
| 678 |
+
const dueDiligenceStatus = document.getElementById("dueDiligenceStatus").value;
|
| 679 |
+
if (dueDiligenceStatus !== "APPROVED") {
|
| 680 |
+
setStatusState(
|
| 681 |
+
"scoreStatus",
|
| 682 |
+
`API not triggered because due diligence is ${dueDiligenceStatus}. Select Approved to call the scorecard.`,
|
| 683 |
+
"error"
|
| 684 |
+
);
|
| 685 |
+
return;
|
| 686 |
+
}
|
| 687 |
+
document.getElementById("mode").value = "generate-scorecard";
|
| 688 |
+
refreshRequestPreview();
|
| 689 |
+
await runScorecard();
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
async function getSavedResult() {
|
| 693 |
+
try {
|
| 694 |
+
const leadId = document.getElementById("leadId").value;
|
| 695 |
+
setStatus("scoreStatus", "Fetching saved result...");
|
| 696 |
+
const result = await api(`/scorecard/${encodeURIComponent(leadId)}`);
|
| 697 |
+
renderScore(result.body);
|
| 698 |
+
setStatusState("scoreStatus", `HTTP ${result.status} ${result.statusText} - saved result loaded.`, "ok");
|
| 699 |
+
} catch (error) {
|
| 700 |
+
setStatusState("scoreStatus", `Error: ${error.message}`, "error");
|
| 701 |
+
}
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
async function loadRules() {
|
| 705 |
+
try {
|
| 706 |
+
setStatus("ruleStatus", "Loading rules...");
|
| 707 |
+
const tenant = encodeURIComponent(document.getElementById("tenantId").value || "default");
|
| 708 |
+
const product = encodeURIComponent(document.getElementById("productType").value || "TWO_WHEELER_LOAN");
|
| 709 |
+
const result = await api(`/rules?tenant_id=${tenant}&product_type=${product}`);
|
| 710 |
+
const rules = result.body;
|
| 711 |
+
const rows = rules.map(rule => `
|
| 712 |
+
<tr>
|
| 713 |
+
<td>${rule.id}</td>
|
| 714 |
+
<td>${rule.rule_code}<br><small>${rule.reason_code}</small></td>
|
| 715 |
+
<td>${rule.rule_type}</td>
|
| 716 |
+
<td>${rule.field_name} ${rule.operator} ${rule.field_value}</td>
|
| 717 |
+
<td>${rule.score_value ?? ""} ${rule.decision_action ?? ""}</td>
|
| 718 |
+
<td>${rule.is_active ? "Active" : "Disabled"}</td>
|
| 719 |
+
<td>
|
| 720 |
+
<button class="ghost" onclick='editRule(${JSON.stringify(rule)})'>Edit</button>
|
| 721 |
+
<button class="${rule.is_active ? "danger" : "secondary"}" onclick="toggleRule(${rule.id}, ${!rule.is_active})">
|
| 722 |
+
${rule.is_active ? "Disable" : "Enable"}
|
| 723 |
+
</button>
|
| 724 |
+
</td>
|
| 725 |
+
</tr>
|
| 726 |
+
`).join("");
|
| 727 |
+
document.getElementById("rulesTable").innerHTML = rows;
|
| 728 |
+
setStatus("ruleStatus", `${rules.length} rules loaded.`);
|
| 729 |
+
} catch (error) {
|
| 730 |
+
setStatus("ruleStatus", `Error: ${error.message}`);
|
| 731 |
+
}
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
function buildRulePayload() {
|
| 735 |
+
const scoreValue = document.getElementById("scoreValue").value;
|
| 736 |
+
return {
|
| 737 |
+
tenant_id: document.getElementById("tenantId").value || "default",
|
| 738 |
+
product_type: document.getElementById("productType").value || "TWO_WHEELER_LOAN",
|
| 739 |
+
rule_code: document.getElementById("ruleCode").value,
|
| 740 |
+
rule_name: document.getElementById("ruleName").value,
|
| 741 |
+
rule_type: document.getElementById("ruleType").value,
|
| 742 |
+
field_name: document.getElementById("fieldName").value,
|
| 743 |
+
operator: document.getElementById("operator").value,
|
| 744 |
+
field_value: document.getElementById("fieldValue").value,
|
| 745 |
+
score_value: scoreValue ? Number(scoreValue) : null,
|
| 746 |
+
score_component: document.getElementById("scoreComponent").value || null,
|
| 747 |
+
decision_action: document.getElementById("decisionAction").value || null,
|
| 748 |
+
reason_code: document.getElementById("reasonCode").value,
|
| 749 |
+
priority: Number(document.getElementById("priority").value || 100),
|
| 750 |
+
is_active: true
|
| 751 |
+
};
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
async function saveRule() {
|
| 755 |
+
const editingRuleId = document.getElementById("editingRuleId").value;
|
| 756 |
+
if (editingRuleId) {
|
| 757 |
+
await updateRule(editingRuleId);
|
| 758 |
+
} else {
|
| 759 |
+
await createRule();
|
| 760 |
+
}
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
async function createRule() {
|
| 764 |
+
try {
|
| 765 |
+
setStatus("ruleStatus", "Creating rule...");
|
| 766 |
+
const payload = buildRulePayload();
|
| 767 |
+
await api("/rules", { method: "POST", body: JSON.stringify(payload) });
|
| 768 |
+
clearRuleForm();
|
| 769 |
+
await loadRules();
|
| 770 |
+
setStatus("ruleStatus", "Rule created.");
|
| 771 |
+
} catch (error) {
|
| 772 |
+
setStatus("ruleStatus", `Error: ${error.message}`);
|
| 773 |
+
}
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
async function updateRule(ruleId) {
|
| 777 |
+
try {
|
| 778 |
+
setStatus("ruleStatus", "Updating rule...");
|
| 779 |
+
const payload = buildRulePayload();
|
| 780 |
+
delete payload.tenant_id;
|
| 781 |
+
delete payload.product_type;
|
| 782 |
+
delete payload.rule_code;
|
| 783 |
+
delete payload.rule_type;
|
| 784 |
+
payload.actor = "admin-ui";
|
| 785 |
+
await api(`/rules/${ruleId}`, { method: "PUT", body: JSON.stringify(payload) });
|
| 786 |
+
clearRuleForm();
|
| 787 |
+
await loadRules();
|
| 788 |
+
setStatus("ruleStatus", "Rule updated.");
|
| 789 |
+
} catch (error) {
|
| 790 |
+
setStatus("ruleStatus", `Error: ${error.message}`);
|
| 791 |
+
}
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
function editRule(rule) {
|
| 795 |
+
document.getElementById("editingRuleId").value = rule.id;
|
| 796 |
+
document.getElementById("ruleCode").value = rule.rule_code || "";
|
| 797 |
+
document.getElementById("ruleName").value = rule.rule_name || "";
|
| 798 |
+
document.getElementById("ruleType").value = rule.rule_type || "SCORE";
|
| 799 |
+
document.getElementById("fieldName").value = rule.field_name || "";
|
| 800 |
+
document.getElementById("operator").value = rule.operator || "=";
|
| 801 |
+
document.getElementById("fieldValue").value = rule.field_value || "";
|
| 802 |
+
document.getElementById("scoreValue").value = rule.score_value ?? "";
|
| 803 |
+
document.getElementById("scoreComponent").value = rule.score_component || "";
|
| 804 |
+
document.getElementById("decisionAction").value = rule.decision_action || "";
|
| 805 |
+
document.getElementById("reasonCode").value = rule.reason_code || "";
|
| 806 |
+
document.getElementById("priority").value = rule.priority || 100;
|
| 807 |
+
document.getElementById("saveRuleButton").textContent = "Save Rule Changes";
|
| 808 |
+
document.getElementById("editText").textContent = `Editing #${rule.id} - ${rule.rule_code}`;
|
| 809 |
+
document.getElementById("editBanner").style.display = "flex";
|
| 810 |
+
setStatus("ruleStatus", "Rule loaded into form. Change values and save.");
|
| 811 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
async function toggleRule(id, isActive) {
|
| 815 |
+
try {
|
| 816 |
+
await api(`/rules/${id}/status`, {
|
| 817 |
+
method: "PATCH",
|
| 818 |
+
body: JSON.stringify({ is_active: isActive, actor: "admin-ui" })
|
| 819 |
+
});
|
| 820 |
+
await loadRules();
|
| 821 |
+
} catch (error) {
|
| 822 |
+
setStatus("ruleStatus", `Error: ${error.message}`);
|
| 823 |
+
}
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
function clearRuleForm() {
|
| 827 |
+
[
|
| 828 |
+
"ruleCode",
|
| 829 |
+
"ruleName",
|
| 830 |
+
"fieldName",
|
| 831 |
+
"fieldValue",
|
| 832 |
+
"scoreValue",
|
| 833 |
+
"scoreComponent",
|
| 834 |
+
"reasonCode"
|
| 835 |
+
].forEach(id => document.getElementById(id).value = "");
|
| 836 |
+
document.getElementById("priority").value = "100";
|
| 837 |
+
cancelEdit();
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
function cancelEdit() {
|
| 841 |
+
document.getElementById("editingRuleId").value = "";
|
| 842 |
+
document.getElementById("saveRuleButton").textContent = "Create Rule";
|
| 843 |
+
document.getElementById("editBanner").style.display = "none";
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
async function exportRules() {
|
| 847 |
+
try {
|
| 848 |
+
const tenant = encodeURIComponent(document.getElementById("tenantId").value || "default");
|
| 849 |
+
const product = encodeURIComponent(document.getElementById("productType").value || "TWO_WHEELER_LOAN");
|
| 850 |
+
const result = await api(`/rules?tenant_id=${tenant}&product_type=${product}`);
|
| 851 |
+
const rules = result.body;
|
| 852 |
+
const blob = new Blob([JSON.stringify(rules, null, 2)], { type: "application/json" });
|
| 853 |
+
const url = URL.createObjectURL(blob);
|
| 854 |
+
const link = document.createElement("a");
|
| 855 |
+
link.href = url;
|
| 856 |
+
link.download = `${product}_rules.json`;
|
| 857 |
+
link.click();
|
| 858 |
+
URL.revokeObjectURL(url);
|
| 859 |
+
setStatus("ruleStatus", "Rules exported.");
|
| 860 |
+
} catch (error) {
|
| 861 |
+
setStatus("ruleStatus", `Error: ${error.message}`);
|
| 862 |
+
}
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
function openDocs() {
|
| 866 |
+
window.open(buildUrl("/docs"), "_blank");
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
async function copyCurl() {
|
| 870 |
+
try {
|
| 871 |
+
const payload = readPayload();
|
| 872 |
+
const path = getScorecardPath();
|
| 873 |
+
renderRequest(path, payload);
|
| 874 |
+
const curl = [
|
| 875 |
+
`curl -X POST "${buildUrl(path)}"`,
|
| 876 |
+
`-H "Content-Type: application/json"`,
|
| 877 |
+
`-d '${JSON.stringify(payload)}'`
|
| 878 |
+
].join(" ");
|
| 879 |
+
await navigator.clipboard.writeText(curl);
|
| 880 |
+
setStatusState("scoreStatus", "cURL copied.", "ok");
|
| 881 |
+
} catch (error) {
|
| 882 |
+
setStatusState("scoreStatus", `Error: ${error.message}`, "error");
|
| 883 |
+
}
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
document.getElementById("apiBaseUrl").value = window.location.origin;
|
| 887 |
+
document.getElementById("apiBaseUrl").addEventListener("input", refreshRequestPreview);
|
| 888 |
+
loadSample();
|
| 889 |
+
loadRules();
|
| 890 |
+
</script>
|
| 891 |
+
</body>
|
| 892 |
+
</html>
|
init_db.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
|
| 3 |
+
from app.db.session import Base, engine
|
| 4 |
+
from app.models import RuleMaster
|
| 5 |
+
from app.services.seed_rules import seed_all_product_rules
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def create_database() -> None:
|
| 9 |
+
Base.metadata.create_all(bind=engine)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def seed_database(db: Session) -> int:
|
| 13 |
+
return seed_all_product_rules(db)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
from app.db.session import SessionLocal
|
| 18 |
+
|
| 19 |
+
create_database()
|
| 20 |
+
session = SessionLocal()
|
| 21 |
+
try:
|
| 22 |
+
inserted = seed_database(session)
|
| 23 |
+
session.commit()
|
| 24 |
+
print(f"Seeded {inserted} rules")
|
| 25 |
+
print(f"Total rules: {session.query(RuleMaster).count()}")
|
| 26 |
+
finally:
|
| 27 |
+
session.close()
|
main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
|
| 4 |
+
from app.api.routes import health, rules, scorecard, ui
|
| 5 |
+
from app.core.config import get_settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def create_app() -> FastAPI:
|
| 9 |
+
settings = get_settings()
|
| 10 |
+
app = FastAPI(
|
| 11 |
+
title=settings.app_name,
|
| 12 |
+
version="0.1.0",
|
| 13 |
+
description="Configurable STP scorecard engine for two wheeler loan LOS integrations.",
|
| 14 |
+
)
|
| 15 |
+
app.include_router(health.router)
|
| 16 |
+
app.include_router(scorecard.router)
|
| 17 |
+
app.include_router(rules.router)
|
| 18 |
+
app.include_router(ui.router)
|
| 19 |
+
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
| 20 |
+
return app
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
app = create_app()
|
operators.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal, InvalidOperation
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _to_bool(value: Any) -> bool | None:
|
| 6 |
+
if isinstance(value, bool):
|
| 7 |
+
return value
|
| 8 |
+
if isinstance(value, str):
|
| 9 |
+
lowered = value.strip().lower()
|
| 10 |
+
if lowered in {"true", "1", "yes", "y"}:
|
| 11 |
+
return True
|
| 12 |
+
if lowered in {"false", "0", "no", "n"}:
|
| 13 |
+
return False
|
| 14 |
+
return None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _to_decimal(value: Any) -> Decimal | None:
|
| 18 |
+
if value is None:
|
| 19 |
+
return None
|
| 20 |
+
try:
|
| 21 |
+
return Decimal(str(value))
|
| 22 |
+
except (InvalidOperation, ValueError):
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _split_values(expected: str) -> list[str]:
|
| 27 |
+
return [item.strip() for item in expected.split(",") if item.strip()]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def evaluate_operator(actual: Any, operator: str, expected: str) -> bool:
|
| 31 |
+
op = operator.strip().upper()
|
| 32 |
+
if actual is None:
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
actual_bool = _to_bool(actual)
|
| 36 |
+
expected_bool = _to_bool(expected)
|
| 37 |
+
if actual_bool is not None and expected_bool is not None and op in {"=", "==", "!=", "<>"}:
|
| 38 |
+
return actual_bool == expected_bool if op in {"=", "=="} else actual_bool != expected_bool
|
| 39 |
+
|
| 40 |
+
actual_num = _to_decimal(actual)
|
| 41 |
+
if op == "BETWEEN":
|
| 42 |
+
bounds = _split_values(expected)
|
| 43 |
+
if actual_num is None or len(bounds) != 2:
|
| 44 |
+
return False
|
| 45 |
+
low = _to_decimal(bounds[0])
|
| 46 |
+
high = _to_decimal(bounds[1])
|
| 47 |
+
return low is not None and high is not None and low <= actual_num <= high
|
| 48 |
+
|
| 49 |
+
expected_num = _to_decimal(expected)
|
| 50 |
+
if actual_num is not None and expected_num is not None:
|
| 51 |
+
if op in {"=", "=="}:
|
| 52 |
+
return actual_num == expected_num
|
| 53 |
+
if op in {"!=", "<>"}:
|
| 54 |
+
return actual_num != expected_num
|
| 55 |
+
if op == ">":
|
| 56 |
+
return actual_num > expected_num
|
| 57 |
+
if op == ">=":
|
| 58 |
+
return actual_num >= expected_num
|
| 59 |
+
if op == "<":
|
| 60 |
+
return actual_num < expected_num
|
| 61 |
+
if op == "<=":
|
| 62 |
+
return actual_num <= expected_num
|
| 63 |
+
|
| 64 |
+
actual_str = str(actual).strip().upper()
|
| 65 |
+
expected_str = str(expected).strip().upper()
|
| 66 |
+
if op in {"=", "=="}:
|
| 67 |
+
return actual_str == expected_str
|
| 68 |
+
if op in {"!=", "<>"}:
|
| 69 |
+
return actual_str != expected_str
|
| 70 |
+
if op in {"IN", "NOT_IN"}:
|
| 71 |
+
values = {value.upper() for value in _split_values(expected)}
|
| 72 |
+
matched = actual_str in values
|
| 73 |
+
return matched if op == "IN" else not matched
|
| 74 |
+
|
| 75 |
+
raise ValueError(f"Unsupported operator: {operator}")
|
| 76 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alembic==1.13.2
|
| 2 |
+
fastapi==0.115.0
|
| 3 |
+
httpx==0.27.2
|
| 4 |
+
passlib[bcrypt]==1.7.4
|
| 5 |
+
psycopg[binary]==3.2.1
|
| 6 |
+
pydantic-settings==2.5.2
|
| 7 |
+
python-jose[cryptography]==3.3.0
|
| 8 |
+
python-multipart==0.0.9
|
| 9 |
+
redis==5.0.8
|
| 10 |
+
sqlalchemy==2.0.34
|
| 11 |
+
uvicorn[standard]==0.30.6
|
| 12 |
+
|
rule.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 5 |
+
|
| 6 |
+
from app.core.enums import DecisionAction, RuleType
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RuleBase(BaseModel):
|
| 10 |
+
tenant_id: str = "default"
|
| 11 |
+
product_type: str = "TWO_WHEELER_LOAN"
|
| 12 |
+
dealer_id: str | None = None
|
| 13 |
+
rule_code: str = Field(min_length=2, max_length=100)
|
| 14 |
+
rule_name: str = Field(min_length=2, max_length=255)
|
| 15 |
+
rule_type: RuleType
|
| 16 |
+
field_name: str = Field(min_length=1, max_length=100)
|
| 17 |
+
operator: str = Field(min_length=1, max_length=20)
|
| 18 |
+
field_value: str = Field(min_length=1, max_length=255)
|
| 19 |
+
score_value: Decimal | None = None
|
| 20 |
+
score_component: str | None = None
|
| 21 |
+
decision_action: DecisionAction | None = None
|
| 22 |
+
reason_code: str = Field(min_length=2, max_length=100)
|
| 23 |
+
priority: int = 100
|
| 24 |
+
is_active: bool = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class RuleCreate(RuleBase):
|
| 28 |
+
created_by: str | None = "system"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class RuleUpdate(BaseModel):
|
| 32 |
+
rule_name: str | None = None
|
| 33 |
+
field_name: str | None = None
|
| 34 |
+
operator: str | None = None
|
| 35 |
+
field_value: str | None = None
|
| 36 |
+
score_value: Decimal | None = None
|
| 37 |
+
score_component: str | None = None
|
| 38 |
+
decision_action: DecisionAction | None = None
|
| 39 |
+
reason_code: str | None = None
|
| 40 |
+
priority: int | None = None
|
| 41 |
+
is_active: bool | None = None
|
| 42 |
+
actor: str | None = "system"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RuleStatusUpdate(BaseModel):
|
| 46 |
+
is_active: bool
|
| 47 |
+
actor: str | None = "system"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class RuleResponse(RuleBase):
|
| 51 |
+
id: int
|
| 52 |
+
version: int
|
| 53 |
+
created_by: str | None
|
| 54 |
+
created_at: datetime
|
| 55 |
+
updated_at: datetime
|
| 56 |
+
|
| 57 |
+
model_config = ConfigDict(from_attributes=True)
|
| 58 |
+
|
rule_engine.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
|
| 4 |
+
from app.core.enums import RuleType
|
| 5 |
+
from app.models.rule import RuleMaster
|
| 6 |
+
from app.services.operators import evaluate_operator
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class RuleEvaluation:
|
| 11 |
+
matched: list[RuleMaster] = field(default_factory=list)
|
| 12 |
+
skipped: list[str] = field(default_factory=list)
|
| 13 |
+
|
| 14 |
+
@property
|
| 15 |
+
def codes(self) -> list[str]:
|
| 16 |
+
return [rule.rule_code for rule in self.matched]
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def reason_codes(self) -> list[str]:
|
| 20 |
+
return [rule.reason_code for rule in self.matched]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class RuleEngine:
|
| 24 |
+
def evaluate(self, rules: list[RuleMaster], context: dict) -> RuleEvaluation:
|
| 25 |
+
result = RuleEvaluation()
|
| 26 |
+
for rule in sorted(rules, key=lambda item: item.priority):
|
| 27 |
+
try:
|
| 28 |
+
if evaluate_operator(context.get(rule.field_name), rule.operator, rule.field_value):
|
| 29 |
+
result.matched.append(rule)
|
| 30 |
+
else:
|
| 31 |
+
result.skipped.append(rule.rule_code)
|
| 32 |
+
except ValueError:
|
| 33 |
+
result.skipped.append(rule.rule_code)
|
| 34 |
+
return result
|
| 35 |
+
|
| 36 |
+
def score(self, rules: list[RuleMaster], context: dict) -> tuple[Decimal, dict[str, Decimal], RuleEvaluation]:
|
| 37 |
+
score_rules = [rule for rule in rules if rule.rule_type == RuleType.SCORE]
|
| 38 |
+
evaluation = self.evaluate(score_rules, context)
|
| 39 |
+
total = Decimal("0")
|
| 40 |
+
breakup: dict[str, Decimal] = {}
|
| 41 |
+
for rule in evaluation.matched:
|
| 42 |
+
value = Decimal(rule.score_value or 0)
|
| 43 |
+
component = rule.score_component or "other"
|
| 44 |
+
breakup[component] = breakup.get(component, Decimal("0")) + value
|
| 45 |
+
total += value
|
| 46 |
+
capped = max(Decimal("0"), min(Decimal("100"), total))
|
| 47 |
+
return capped, breakup, evaluation
|
| 48 |
+
|
rule_repository.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from redis import Redis
|
| 6 |
+
from sqlalchemy import and_, or_, select
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.core.config import get_settings
|
| 10 |
+
from app.models.rule import RuleMaster
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _serialize_rule(rule: RuleMaster) -> dict[str, Any]:
|
| 14 |
+
return {
|
| 15 |
+
"id": rule.id,
|
| 16 |
+
"tenant_id": rule.tenant_id,
|
| 17 |
+
"product_type": rule.product_type,
|
| 18 |
+
"dealer_id": rule.dealer_id,
|
| 19 |
+
"rule_code": rule.rule_code,
|
| 20 |
+
"rule_name": rule.rule_name,
|
| 21 |
+
"rule_type": rule.rule_type,
|
| 22 |
+
"field_name": rule.field_name,
|
| 23 |
+
"operator": rule.operator,
|
| 24 |
+
"field_value": rule.field_value,
|
| 25 |
+
"score_value": str(rule.score_value) if rule.score_value is not None else None,
|
| 26 |
+
"score_component": rule.score_component,
|
| 27 |
+
"decision_action": rule.decision_action,
|
| 28 |
+
"reason_code": rule.reason_code,
|
| 29 |
+
"priority": rule.priority,
|
| 30 |
+
"version": rule.version,
|
| 31 |
+
"is_active": rule.is_active,
|
| 32 |
+
"created_by": rule.created_by,
|
| 33 |
+
"created_at": rule.created_at.isoformat(),
|
| 34 |
+
"updated_at": rule.updated_at.isoformat(),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _hydrate_rule(data: dict[str, Any]) -> RuleMaster:
|
| 39 |
+
rule = RuleMaster(**{k: v for k, v in data.items() if k not in {"created_at", "updated_at"}})
|
| 40 |
+
if data.get("score_value") is not None:
|
| 41 |
+
rule.score_value = Decimal(data["score_value"])
|
| 42 |
+
return rule
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RuleRepository:
|
| 46 |
+
def __init__(self) -> None:
|
| 47 |
+
self.settings = get_settings()
|
| 48 |
+
self.redis: Redis | None = None
|
| 49 |
+
if self.settings.redis_url:
|
| 50 |
+
try:
|
| 51 |
+
self.redis = Redis.from_url(self.settings.redis_url, decode_responses=True)
|
| 52 |
+
except Exception:
|
| 53 |
+
self.redis = None
|
| 54 |
+
|
| 55 |
+
def cache_key(self, tenant_id: str, product_type: str, dealer_id: str | None) -> str:
|
| 56 |
+
dealer = dealer_id or "ALL"
|
| 57 |
+
return f"rules:{tenant_id}:{product_type}:{dealer}"
|
| 58 |
+
|
| 59 |
+
def get_active_rules(
|
| 60 |
+
self, db: Session, tenant_id: str, product_type: str, dealer_id: str | None
|
| 61 |
+
) -> list[RuleMaster]:
|
| 62 |
+
key = self.cache_key(tenant_id, product_type, dealer_id)
|
| 63 |
+
if self.redis:
|
| 64 |
+
try:
|
| 65 |
+
cached = self.redis.get(key)
|
| 66 |
+
if cached:
|
| 67 |
+
return [_hydrate_rule(item) for item in json.loads(cached)]
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
stmt = (
|
| 72 |
+
select(RuleMaster)
|
| 73 |
+
.where(
|
| 74 |
+
RuleMaster.is_active.is_(True),
|
| 75 |
+
RuleMaster.tenant_id == tenant_id,
|
| 76 |
+
RuleMaster.product_type == product_type,
|
| 77 |
+
or_(RuleMaster.dealer_id.is_(None), RuleMaster.dealer_id == dealer_id),
|
| 78 |
+
)
|
| 79 |
+
.order_by(RuleMaster.priority.asc(), RuleMaster.id.asc())
|
| 80 |
+
)
|
| 81 |
+
rules = list(db.scalars(stmt).all())
|
| 82 |
+
if self.redis:
|
| 83 |
+
try:
|
| 84 |
+
self.redis.setex(key, 300, json.dumps([_serialize_rule(rule) for rule in rules]))
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
return rules
|
| 88 |
+
|
| 89 |
+
def invalidate(self, tenant_id: str, product_type: str, dealer_id: str | None = None) -> None:
|
| 90 |
+
if not self.redis:
|
| 91 |
+
return
|
| 92 |
+
keys = [
|
| 93 |
+
self.cache_key(tenant_id, product_type, dealer_id),
|
| 94 |
+
self.cache_key(tenant_id, product_type, None),
|
| 95 |
+
]
|
| 96 |
+
try:
|
| 97 |
+
self.redis.delete(*set(keys))
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
def list_rules(
|
| 102 |
+
self,
|
| 103 |
+
db: Session,
|
| 104 |
+
tenant_id: str | None = None,
|
| 105 |
+
product_type: str | None = None,
|
| 106 |
+
dealer_id: str | None = None,
|
| 107 |
+
rule_type: str | None = None,
|
| 108 |
+
is_active: bool | None = None,
|
| 109 |
+
) -> list[RuleMaster]:
|
| 110 |
+
filters = []
|
| 111 |
+
if tenant_id:
|
| 112 |
+
filters.append(RuleMaster.tenant_id == tenant_id)
|
| 113 |
+
if product_type:
|
| 114 |
+
filters.append(RuleMaster.product_type == product_type)
|
| 115 |
+
if dealer_id:
|
| 116 |
+
filters.append(RuleMaster.dealer_id == dealer_id)
|
| 117 |
+
if rule_type:
|
| 118 |
+
filters.append(RuleMaster.rule_type == rule_type)
|
| 119 |
+
if is_active is not None:
|
| 120 |
+
filters.append(RuleMaster.is_active.is_(is_active))
|
| 121 |
+
stmt = select(RuleMaster).order_by(RuleMaster.priority.asc(), RuleMaster.id.asc())
|
| 122 |
+
if filters:
|
| 123 |
+
stmt = stmt.where(and_(*filters))
|
| 124 |
+
return list(db.scalars(stmt).all())
|
| 125 |
+
|
rules.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 2 |
+
from sqlalchemy import func, select
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
from app.api.dependencies import require_auth
|
| 6 |
+
from app.db.session import get_db
|
| 7 |
+
from app.models.rule import RuleMaster
|
| 8 |
+
from app.schemas.rule import RuleCreate, RuleResponse, RuleStatusUpdate, RuleUpdate
|
| 9 |
+
from app.services.audit import rule_to_dict, write_rule_audit
|
| 10 |
+
from app.services.rule_repository import RuleRepository
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/rules", tags=["Rule Management"], dependencies=[Depends(require_auth)])
|
| 13 |
+
repo = RuleRepository()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("", response_model=list[RuleResponse])
|
| 17 |
+
def list_rules(
|
| 18 |
+
tenant_id: str | None = None,
|
| 19 |
+
product_type: str | None = None,
|
| 20 |
+
dealer_id: str | None = None,
|
| 21 |
+
rule_type: str | None = None,
|
| 22 |
+
is_active: bool | None = Query(default=None),
|
| 23 |
+
db: Session = Depends(get_db),
|
| 24 |
+
) -> list[RuleMaster]:
|
| 25 |
+
return repo.list_rules(db, tenant_id, product_type, dealer_id, rule_type, is_active)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("", response_model=RuleResponse, status_code=status.HTTP_201_CREATED)
|
| 29 |
+
def create_rule(payload: RuleCreate, db: Session = Depends(get_db)) -> RuleMaster:
|
| 30 |
+
dealer_filter = (
|
| 31 |
+
RuleMaster.dealer_id.is_(None)
|
| 32 |
+
if payload.dealer_id is None
|
| 33 |
+
else RuleMaster.dealer_id == payload.dealer_id
|
| 34 |
+
)
|
| 35 |
+
latest_version = db.scalar(
|
| 36 |
+
select(func.max(RuleMaster.version)).where(
|
| 37 |
+
RuleMaster.rule_code == payload.rule_code,
|
| 38 |
+
RuleMaster.tenant_id == payload.tenant_id,
|
| 39 |
+
RuleMaster.product_type == payload.product_type,
|
| 40 |
+
dealer_filter,
|
| 41 |
+
)
|
| 42 |
+
)
|
| 43 |
+
rule = RuleMaster(**payload.model_dump(), version=(latest_version or 0) + 1)
|
| 44 |
+
db.add(rule)
|
| 45 |
+
db.flush()
|
| 46 |
+
write_rule_audit(db, rule.id, "CREATE", payload.created_by, None, rule_to_dict(rule))
|
| 47 |
+
db.commit()
|
| 48 |
+
db.refresh(rule)
|
| 49 |
+
repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
|
| 50 |
+
return rule
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.put("/{rule_id}", response_model=RuleResponse)
|
| 54 |
+
def update_rule(rule_id: int, payload: RuleUpdate, db: Session = Depends(get_db)) -> RuleMaster:
|
| 55 |
+
rule = db.get(RuleMaster, rule_id)
|
| 56 |
+
if not rule:
|
| 57 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
| 58 |
+
before = rule_to_dict(rule)
|
| 59 |
+
updates = payload.model_dump(exclude_unset=True)
|
| 60 |
+
actor = updates.pop("actor", "system")
|
| 61 |
+
for field, value in updates.items():
|
| 62 |
+
setattr(rule, field, value)
|
| 63 |
+
write_rule_audit(db, rule.id, "UPDATE", actor, before, rule_to_dict(rule))
|
| 64 |
+
db.commit()
|
| 65 |
+
db.refresh(rule)
|
| 66 |
+
repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
|
| 67 |
+
return rule
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.patch("/{rule_id}/status", response_model=RuleResponse)
|
| 71 |
+
def update_rule_status(
|
| 72 |
+
rule_id: int, payload: RuleStatusUpdate, db: Session = Depends(get_db)
|
| 73 |
+
) -> RuleMaster:
|
| 74 |
+
rule = db.get(RuleMaster, rule_id)
|
| 75 |
+
if not rule:
|
| 76 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
| 77 |
+
before = rule_to_dict(rule)
|
| 78 |
+
rule.is_active = payload.is_active
|
| 79 |
+
write_rule_audit(db, rule.id, "STATUS_UPDATE", payload.actor, before, rule_to_dict(rule))
|
| 80 |
+
db.commit()
|
| 81 |
+
db.refresh(rule)
|
| 82 |
+
repo.invalidate(rule.tenant_id, rule.product_type, rule.dealer_id)
|
| 83 |
+
return rule
|
runtime.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python-3.11.9
|
| 2 |
+
|
scorecard.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:772cdc993e65c56670c27fa77ae32e7c17df26231adeb8a603b06cf49c18e400
|
| 3 |
+
size 184320
|
scorecard.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ApplicationDetails(BaseModel):
|
| 8 |
+
leadId: str
|
| 9 |
+
customerName: str | None = None
|
| 10 |
+
mobile: str | None = None
|
| 11 |
+
employmentType: str | None = None
|
| 12 |
+
monthlyIncome: Decimal | None = None
|
| 13 |
+
existingEMI: Decimal | None = None
|
| 14 |
+
foir: Decimal | None = None
|
| 15 |
+
loanAmount: Decimal | None = None
|
| 16 |
+
assetCost: Decimal | None = None
|
| 17 |
+
ltv: Decimal | None = None
|
| 18 |
+
tenure: int | None = None
|
| 19 |
+
dealerId: str | None = None
|
| 20 |
+
state: str | None = None
|
| 21 |
+
city: str | None = None
|
| 22 |
+
pincode: str | None = None
|
| 23 |
+
negativeArea: bool | None = None
|
| 24 |
+
fraudFlag: bool | None = False
|
| 25 |
+
blacklistMatch: bool | None = False
|
| 26 |
+
fakeKyc: bool | None = False
|
| 27 |
+
panMismatch: bool | None = False
|
| 28 |
+
faceMatchScore: Decimal | None = None
|
| 29 |
+
panVerified: bool | None = None
|
| 30 |
+
aadhaarVerified: bool | None = None
|
| 31 |
+
bankingSurrogate: bool | None = None
|
| 32 |
+
coApplicant: bool | None = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class BureauData(BaseModel):
|
| 36 |
+
cibilScore: int | None = None
|
| 37 |
+
creditVintageMonths: int | None = None
|
| 38 |
+
activeAccounts: int | None = None
|
| 39 |
+
closedAccounts: int | None = None
|
| 40 |
+
overdueAmount: Decimal | None = None
|
| 41 |
+
writtenOffAccounts: int | None = None
|
| 42 |
+
settledAccounts: int | None = None
|
| 43 |
+
suitFiledAccounts: int | None = 0
|
| 44 |
+
active90PlusDpd: bool | None = False
|
| 45 |
+
maxDPD: int | None = None
|
| 46 |
+
dpd30Count: int | None = None
|
| 47 |
+
dpd60Count: int | None = None
|
| 48 |
+
dpd90Count: int | None = None
|
| 49 |
+
creditEnquiries6M: int | None = None
|
| 50 |
+
creditEnquiries12M: int | None = None
|
| 51 |
+
activeUnsecuredLoans: int | None = None
|
| 52 |
+
activeSecuredLoans: int | None = None
|
| 53 |
+
jointAccounts: int | None = None
|
| 54 |
+
creditUtilization: Decimal | None = None
|
| 55 |
+
bureauRisk: str | None = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ScorecardRequest(BaseModel):
|
| 59 |
+
application: ApplicationDetails
|
| 60 |
+
bureau: BureauData
|
| 61 |
+
tenantId: str = "default"
|
| 62 |
+
productType: str = "TWO_WHEELER_LOAN"
|
| 63 |
+
forceRecalculate: bool = False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ScorecardResponse(BaseModel):
|
| 67 |
+
leadId: str
|
| 68 |
+
finalScore: Decimal
|
| 69 |
+
decision: str
|
| 70 |
+
underwritingRequired: bool
|
| 71 |
+
rejectFlag: bool
|
| 72 |
+
journeyAction: str
|
| 73 |
+
bureauRisk: str
|
| 74 |
+
reasonCodes: list[str]
|
| 75 |
+
triggeredRules: list[str]
|
| 76 |
+
scoreBreakup: dict[str, Decimal]
|
| 77 |
+
|
| 78 |
+
model_config = ConfigDict(from_attributes=True)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SimulationResponse(ScorecardResponse):
|
| 82 |
+
persisted: bool = False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class StoredScorecardResponse(ScorecardResponse):
|
| 86 |
+
tenantId: str
|
| 87 |
+
productType: str
|
| 88 |
+
createdAt: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class HealthResponse(BaseModel):
|
| 92 |
+
status: str
|
| 93 |
+
database: str
|
| 94 |
+
redis: str
|
| 95 |
+
details: dict[str, Any] = Field(default_factory=dict)
|
| 96 |
+
|
scorecard_service.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
|
| 7 |
+
from app.core.enums import BureauRisk, JourneyAction, RuleType, ScorecardDecision
|
| 8 |
+
from app.models.audit import ScorecardExecutionAudit
|
| 9 |
+
from app.models.rule import RuleMaster
|
| 10 |
+
from app.models.scorecard import ScorecardResult
|
| 11 |
+
from app.schemas.scorecard import ScorecardRequest, ScorecardResponse
|
| 12 |
+
from app.services.bureau import BureauRiskClassifier
|
| 13 |
+
from app.services.context import build_evaluation_context
|
| 14 |
+
from app.services.hash import stable_payload_hash
|
| 15 |
+
from app.services.rule_engine import RuleEngine
|
| 16 |
+
from app.services.rule_repository import RuleRepository
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ScorecardService:
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
self.rules = RuleRepository()
|
| 22 |
+
self.engine = RuleEngine()
|
| 23 |
+
self.bureau = BureauRiskClassifier()
|
| 24 |
+
|
| 25 |
+
def _existing_result(
|
| 26 |
+
self, db: Session, request: ScorecardRequest, request_hash: str
|
| 27 |
+
) -> ScorecardResult | None:
|
| 28 |
+
stmt = (
|
| 29 |
+
select(ScorecardResult)
|
| 30 |
+
.where(
|
| 31 |
+
ScorecardResult.lead_id == request.application.leadId,
|
| 32 |
+
ScorecardResult.tenant_id == request.tenantId,
|
| 33 |
+
ScorecardResult.product_type == request.productType,
|
| 34 |
+
ScorecardResult.request_hash == request_hash,
|
| 35 |
+
)
|
| 36 |
+
.order_by(ScorecardResult.created_at.desc())
|
| 37 |
+
)
|
| 38 |
+
return db.scalars(stmt).first()
|
| 39 |
+
|
| 40 |
+
def generate(self, db: Session, request: ScorecardRequest, persist: bool = True) -> ScorecardResponse:
|
| 41 |
+
start = time.perf_counter()
|
| 42 |
+
payload_hash = stable_payload_hash(request.model_dump())
|
| 43 |
+
if persist and not request.forceRecalculate:
|
| 44 |
+
existing = self._existing_result(db, request, payload_hash)
|
| 45 |
+
if existing:
|
| 46 |
+
return self._to_response(existing)
|
| 47 |
+
|
| 48 |
+
audit = ScorecardExecutionAudit(
|
| 49 |
+
lead_id=request.application.leadId,
|
| 50 |
+
tenant_id=request.tenantId,
|
| 51 |
+
product_type=request.productType,
|
| 52 |
+
request_hash=payload_hash,
|
| 53 |
+
matched_rules=[],
|
| 54 |
+
skipped_rules=[],
|
| 55 |
+
status="STARTED",
|
| 56 |
+
)
|
| 57 |
+
if persist:
|
| 58 |
+
db.add(audit)
|
| 59 |
+
db.flush()
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
response = self._execute(db, request)
|
| 63 |
+
if persist:
|
| 64 |
+
result = ScorecardResult(
|
| 65 |
+
tenant_id=request.tenantId,
|
| 66 |
+
product_type=request.productType,
|
| 67 |
+
lead_id=response.leadId,
|
| 68 |
+
final_score=response.finalScore,
|
| 69 |
+
decision=response.decision,
|
| 70 |
+
underwriting_required=response.underwritingRequired,
|
| 71 |
+
reject_flag=response.rejectFlag,
|
| 72 |
+
journey_action=response.journeyAction,
|
| 73 |
+
bureau_risk=response.bureauRisk,
|
| 74 |
+
score_breakup={k: str(v) for k, v in response.scoreBreakup.items()},
|
| 75 |
+
triggered_rules=response.triggeredRules,
|
| 76 |
+
reason_codes=response.reasonCodes,
|
| 77 |
+
request_hash=payload_hash,
|
| 78 |
+
)
|
| 79 |
+
db.add(result)
|
| 80 |
+
audit.matched_rules = response.triggeredRules
|
| 81 |
+
audit.status = "SUCCESS"
|
| 82 |
+
audit.latency_ms = int((time.perf_counter() - start) * 1000)
|
| 83 |
+
db.commit()
|
| 84 |
+
db.refresh(result)
|
| 85 |
+
return self._to_response(result)
|
| 86 |
+
return response
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
if persist:
|
| 89 |
+
audit.status = "FAILED"
|
| 90 |
+
audit.error_message = str(exc)
|
| 91 |
+
audit.latency_ms = int((time.perf_counter() - start) * 1000)
|
| 92 |
+
db.commit()
|
| 93 |
+
raise
|
| 94 |
+
|
| 95 |
+
def _execute(self, db: Session, request: ScorecardRequest) -> ScorecardResponse:
|
| 96 |
+
context = build_evaluation_context(request)
|
| 97 |
+
rules = self.rules.get_active_rules(
|
| 98 |
+
db, request.tenantId, request.productType, request.application.dealerId
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
hard_reject = self.engine.evaluate(
|
| 102 |
+
[rule for rule in rules if rule.rule_type == RuleType.HARD_REJECT], context
|
| 103 |
+
)
|
| 104 |
+
score, breakup, score_eval = self.engine.score(rules, context)
|
| 105 |
+
bureau_risk = self.bureau.classify(context)
|
| 106 |
+
non_stp = self.engine.evaluate(
|
| 107 |
+
[rule for rule in rules if rule.rule_type == RuleType.NON_STP], context
|
| 108 |
+
)
|
| 109 |
+
stp_eligibility_rules = [
|
| 110 |
+
rule for rule in rules if rule.rule_type == RuleType.STP_ELIGIBILITY
|
| 111 |
+
]
|
| 112 |
+
stp_eval = self.engine.evaluate(stp_eligibility_rules, context)
|
| 113 |
+
|
| 114 |
+
decision = self._decide(
|
| 115 |
+
score=score,
|
| 116 |
+
hard_reject_rules=hard_reject.matched,
|
| 117 |
+
non_stp_rules=non_stp.matched,
|
| 118 |
+
stp_rules=stp_eligibility_rules,
|
| 119 |
+
stp_matched=stp_eval.matched,
|
| 120 |
+
bureau_risk=bureau_risk,
|
| 121 |
+
)
|
| 122 |
+
underwriting_required = decision in {
|
| 123 |
+
ScorecardDecision.NON_STP,
|
| 124 |
+
ScorecardDecision.CONDITIONAL_STP,
|
| 125 |
+
}
|
| 126 |
+
reject_flag = decision == ScorecardDecision.REJECT
|
| 127 |
+
journey_action = self._journey_action(decision)
|
| 128 |
+
|
| 129 |
+
triggered_rules = (
|
| 130 |
+
hard_reject.codes + score_eval.codes + non_stp.codes + stp_eval.codes
|
| 131 |
+
)
|
| 132 |
+
reason_codes = list(
|
| 133 |
+
dict.fromkeys(
|
| 134 |
+
hard_reject.reason_codes
|
| 135 |
+
+ score_eval.reason_codes
|
| 136 |
+
+ non_stp.reason_codes
|
| 137 |
+
+ stp_eval.reason_codes
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return ScorecardResponse(
|
| 142 |
+
leadId=request.application.leadId,
|
| 143 |
+
finalScore=score,
|
| 144 |
+
decision=decision,
|
| 145 |
+
underwritingRequired=underwriting_required,
|
| 146 |
+
rejectFlag=reject_flag,
|
| 147 |
+
journeyAction=journey_action,
|
| 148 |
+
bureauRisk=bureau_risk,
|
| 149 |
+
reasonCodes=reason_codes,
|
| 150 |
+
triggeredRules=triggered_rules,
|
| 151 |
+
scoreBreakup=breakup,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
def _decide(
|
| 155 |
+
self,
|
| 156 |
+
score: Decimal,
|
| 157 |
+
hard_reject_rules: list[RuleMaster],
|
| 158 |
+
non_stp_rules: list[RuleMaster],
|
| 159 |
+
stp_rules: list[RuleMaster],
|
| 160 |
+
stp_matched: list[RuleMaster],
|
| 161 |
+
bureau_risk: BureauRisk,
|
| 162 |
+
) -> ScorecardDecision:
|
| 163 |
+
if hard_reject_rules:
|
| 164 |
+
return ScorecardDecision.REJECT
|
| 165 |
+
all_stp_rules_match = bool(stp_rules) and len(stp_rules) == len(stp_matched)
|
| 166 |
+
if (
|
| 167 |
+
all_stp_rules_match
|
| 168 |
+
and score >= Decimal("80")
|
| 169 |
+
and not non_stp_rules
|
| 170 |
+
and bureau_risk != BureauRisk.HIGH
|
| 171 |
+
):
|
| 172 |
+
return ScorecardDecision.STP
|
| 173 |
+
if non_stp_rules or bureau_risk == BureauRisk.HIGH:
|
| 174 |
+
return ScorecardDecision.NON_STP
|
| 175 |
+
if Decimal("65") <= score <= Decimal("79"):
|
| 176 |
+
return ScorecardDecision.CONDITIONAL_STP
|
| 177 |
+
return ScorecardDecision.NON_STP
|
| 178 |
+
|
| 179 |
+
def _journey_action(self, decision: ScorecardDecision) -> JourneyAction:
|
| 180 |
+
if decision == ScorecardDecision.STP:
|
| 181 |
+
return JourneyAction.SKIP_UNDERWRITING
|
| 182 |
+
if decision == ScorecardDecision.REJECT:
|
| 183 |
+
return JourneyAction.REJECT_APPLICATION
|
| 184 |
+
return JourneyAction.SEND_TO_UNDERWRITING
|
| 185 |
+
|
| 186 |
+
def _to_response(self, result: ScorecardResult) -> ScorecardResponse:
|
| 187 |
+
return ScorecardResponse(
|
| 188 |
+
leadId=result.lead_id,
|
| 189 |
+
finalScore=result.final_score,
|
| 190 |
+
decision=result.decision,
|
| 191 |
+
underwritingRequired=result.underwriting_required,
|
| 192 |
+
rejectFlag=result.reject_flag,
|
| 193 |
+
journeyAction=result.journey_action,
|
| 194 |
+
bureauRisk=result.bureau_risk,
|
| 195 |
+
reasonCodes=list(result.reason_codes or []),
|
| 196 |
+
triggeredRules=list(result.triggered_rules or []),
|
| 197 |
+
scoreBreakup={k: Decimal(str(v)) for k, v in (result.score_breakup or {}).items()},
|
| 198 |
+
)
|
script.py.mako
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
"""
|
| 7 |
+
from typing import Sequence, Union
|
| 8 |
+
|
| 9 |
+
from alembic import op
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
${imports if imports else ""}
|
| 12 |
+
|
| 13 |
+
revision: str = ${repr(up_revision)}
|
| 14 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 15 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 16 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
${upgrades if upgrades else "pass"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
${downgrades if downgrades else "pass"}
|
| 25 |
+
|
seed_rules.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.core.enums import DecisionAction, RuleType
|
| 7 |
+
from app.models.rule import RuleMaster
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
DEFAULT_RULES = [
|
| 11 |
+
{
|
| 12 |
+
"rule_code": "CIBIL_HIGH",
|
| 13 |
+
"rule_name": "High CIBIL score",
|
| 14 |
+
"rule_type": RuleType.SCORE,
|
| 15 |
+
"field_name": "cibilScore",
|
| 16 |
+
"operator": ">=",
|
| 17 |
+
"field_value": "750",
|
| 18 |
+
"score_value": Decimal("20"),
|
| 19 |
+
"score_component": "cibil",
|
| 20 |
+
"decision_action": DecisionAction.SCORE,
|
| 21 |
+
"reason_code": "HIGH_CIBIL",
|
| 22 |
+
"priority": 100,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"rule_code": "CIBIL_MEDIUM",
|
| 26 |
+
"rule_name": "Medium CIBIL score",
|
| 27 |
+
"rule_type": RuleType.SCORE,
|
| 28 |
+
"field_name": "cibilScore",
|
| 29 |
+
"operator": "BETWEEN",
|
| 30 |
+
"field_value": "700,749",
|
| 31 |
+
"score_value": Decimal("14"),
|
| 32 |
+
"score_component": "cibil",
|
| 33 |
+
"decision_action": DecisionAction.SCORE,
|
| 34 |
+
"reason_code": "MEDIUM_CIBIL",
|
| 35 |
+
"priority": 110,
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"rule_code": "BUREAU_CLEAN",
|
| 39 |
+
"rule_name": "Clean repayment behaviour",
|
| 40 |
+
"rule_type": RuleType.SCORE,
|
| 41 |
+
"field_name": "noDpd",
|
| 42 |
+
"operator": "=",
|
| 43 |
+
"field_value": "true",
|
| 44 |
+
"score_value": Decimal("25"),
|
| 45 |
+
"score_component": "bureau",
|
| 46 |
+
"decision_action": DecisionAction.SCORE,
|
| 47 |
+
"reason_code": "NO_DELINQUENCY",
|
| 48 |
+
"priority": 120,
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"rule_code": "FOIR_GOOD",
|
| 52 |
+
"rule_name": "Low FOIR",
|
| 53 |
+
"rule_type": RuleType.SCORE,
|
| 54 |
+
"field_name": "foir",
|
| 55 |
+
"operator": "<=",
|
| 56 |
+
"field_value": "35",
|
| 57 |
+
"score_value": Decimal("15"),
|
| 58 |
+
"score_component": "foir",
|
| 59 |
+
"decision_action": DecisionAction.SCORE,
|
| 60 |
+
"reason_code": "LOW_FOIR",
|
| 61 |
+
"priority": 130,
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"rule_code": "FOIR_ACCEPTABLE",
|
| 65 |
+
"rule_name": "Acceptable FOIR",
|
| 66 |
+
"rule_type": RuleType.SCORE,
|
| 67 |
+
"field_name": "foir",
|
| 68 |
+
"operator": "BETWEEN",
|
| 69 |
+
"field_value": "36,50",
|
| 70 |
+
"score_value": Decimal("8"),
|
| 71 |
+
"score_component": "foir",
|
| 72 |
+
"decision_action": DecisionAction.SCORE,
|
| 73 |
+
"reason_code": "ACCEPTABLE_FOIR",
|
| 74 |
+
"priority": 131,
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"rule_code": "LTV_GOOD",
|
| 78 |
+
"rule_name": "Low LTV",
|
| 79 |
+
"rule_type": RuleType.SCORE,
|
| 80 |
+
"field_name": "ltv",
|
| 81 |
+
"operator": "<=",
|
| 82 |
+
"field_value": "75",
|
| 83 |
+
"score_value": Decimal("10"),
|
| 84 |
+
"score_component": "ltv",
|
| 85 |
+
"decision_action": DecisionAction.SCORE,
|
| 86 |
+
"reason_code": "LOW_LTV",
|
| 87 |
+
"priority": 140,
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"rule_code": "LTV_ACCEPTABLE",
|
| 91 |
+
"rule_name": "Acceptable LTV",
|
| 92 |
+
"rule_type": RuleType.SCORE,
|
| 93 |
+
"field_name": "ltv",
|
| 94 |
+
"operator": "BETWEEN",
|
| 95 |
+
"field_value": "76,85",
|
| 96 |
+
"score_value": Decimal("8"),
|
| 97 |
+
"score_component": "ltv",
|
| 98 |
+
"decision_action": DecisionAction.SCORE,
|
| 99 |
+
"reason_code": "ACCEPTABLE_LTV",
|
| 100 |
+
"priority": 141,
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"rule_code": "NO_FRAUD",
|
| 104 |
+
"rule_name": "No fraud indicators",
|
| 105 |
+
"rule_type": RuleType.SCORE,
|
| 106 |
+
"field_name": "noFraud",
|
| 107 |
+
"operator": "=",
|
| 108 |
+
"field_value": "true",
|
| 109 |
+
"score_value": Decimal("10"),
|
| 110 |
+
"score_component": "fraud",
|
| 111 |
+
"decision_action": DecisionAction.SCORE,
|
| 112 |
+
"reason_code": "NO_FRAUD",
|
| 113 |
+
"priority": 150,
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"rule_code": "STABLE_EMPLOYMENT",
|
| 117 |
+
"rule_name": "Stable employment type",
|
| 118 |
+
"rule_type": RuleType.SCORE,
|
| 119 |
+
"field_name": "stableEmployment",
|
| 120 |
+
"operator": "=",
|
| 121 |
+
"field_value": "true",
|
| 122 |
+
"score_value": Decimal("10"),
|
| 123 |
+
"score_component": "employment",
|
| 124 |
+
"decision_action": DecisionAction.SCORE,
|
| 125 |
+
"reason_code": "STABLE_EMPLOYMENT",
|
| 126 |
+
"priority": 160,
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"rule_code": "BANKING_SURROGATE_OK",
|
| 130 |
+
"rule_name": "Banking surrogate available",
|
| 131 |
+
"rule_type": RuleType.SCORE,
|
| 132 |
+
"field_name": "bankingSurrogate",
|
| 133 |
+
"operator": "=",
|
| 134 |
+
"field_value": "true",
|
| 135 |
+
"score_value": Decimal("5"),
|
| 136 |
+
"score_component": "banking",
|
| 137 |
+
"decision_action": DecisionAction.SCORE,
|
| 138 |
+
"reason_code": "BANKING_SURROGATE",
|
| 139 |
+
"priority": 170,
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"rule_code": "KYC_VERIFIED",
|
| 143 |
+
"rule_name": "PAN and Aadhaar verified",
|
| 144 |
+
"rule_type": RuleType.SCORE,
|
| 145 |
+
"field_name": "kycVerified",
|
| 146 |
+
"operator": "=",
|
| 147 |
+
"field_value": "true",
|
| 148 |
+
"score_value": Decimal("5"),
|
| 149 |
+
"score_component": "kyc",
|
| 150 |
+
"decision_action": DecisionAction.SCORE,
|
| 151 |
+
"reason_code": "KYC_VERIFIED",
|
| 152 |
+
"priority": 180,
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"rule_code": "WRITTEN_OFF_REJECT",
|
| 156 |
+
"rule_name": "Written off account reject",
|
| 157 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 158 |
+
"field_name": "writtenOffAccounts",
|
| 159 |
+
"operator": ">",
|
| 160 |
+
"field_value": "0",
|
| 161 |
+
"decision_action": DecisionAction.REJECT,
|
| 162 |
+
"reason_code": "WRITTEN_OFF_ACCOUNT",
|
| 163 |
+
"priority": 10,
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"rule_code": "SETTLED_REJECT",
|
| 167 |
+
"rule_name": "Settled account reject",
|
| 168 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 169 |
+
"field_name": "settledAccounts",
|
| 170 |
+
"operator": ">",
|
| 171 |
+
"field_value": "0",
|
| 172 |
+
"decision_action": DecisionAction.REJECT,
|
| 173 |
+
"reason_code": "SETTLED_ACCOUNT",
|
| 174 |
+
"priority": 11,
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"rule_code": "OVERDUE_REJECT",
|
| 178 |
+
"rule_name": "Current overdue above threshold",
|
| 179 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 180 |
+
"field_name": "overdueAmount",
|
| 181 |
+
"operator": ">",
|
| 182 |
+
"field_value": "5000",
|
| 183 |
+
"decision_action": DecisionAction.REJECT,
|
| 184 |
+
"reason_code": "CURRENT_OVERDUE",
|
| 185 |
+
"priority": 12,
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"rule_code": "FRAUD_REJECT",
|
| 189 |
+
"rule_name": "Fraud flag reject",
|
| 190 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 191 |
+
"field_name": "fraudFlag",
|
| 192 |
+
"operator": "=",
|
| 193 |
+
"field_value": "true",
|
| 194 |
+
"decision_action": DecisionAction.REJECT,
|
| 195 |
+
"reason_code": "FRAUD_FLAG",
|
| 196 |
+
"priority": 13,
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"rule_code": "PAN_MISMATCH_REJECT",
|
| 200 |
+
"rule_name": "PAN mismatch reject",
|
| 201 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 202 |
+
"field_name": "panMismatch",
|
| 203 |
+
"operator": "=",
|
| 204 |
+
"field_value": "true",
|
| 205 |
+
"decision_action": DecisionAction.REJECT,
|
| 206 |
+
"reason_code": "PAN_MISMATCH",
|
| 207 |
+
"priority": 14,
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"rule_code": "DPD_90_REJECT",
|
| 211 |
+
"rule_name": "90 plus DPD reject",
|
| 212 |
+
"rule_type": RuleType.HARD_REJECT,
|
| 213 |
+
"field_name": "active90PlusDpd",
|
| 214 |
+
"operator": "=",
|
| 215 |
+
"field_value": "true",
|
| 216 |
+
"decision_action": DecisionAction.REJECT,
|
| 217 |
+
"reason_code": "ACTIVE_90_PLUS_DPD",
|
| 218 |
+
"priority": 15,
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"rule_code": "MAX_DPD_UW",
|
| 222 |
+
"rule_name": "Max DPD above STP threshold",
|
| 223 |
+
"rule_type": RuleType.NON_STP,
|
| 224 |
+
"field_name": "maxDPD",
|
| 225 |
+
"operator": ">",
|
| 226 |
+
"field_value": "30",
|
| 227 |
+
"decision_action": DecisionAction.UW,
|
| 228 |
+
"reason_code": "MAX_DPD_ABOVE_30",
|
| 229 |
+
"priority": 200,
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"rule_code": "DPD_60_UW",
|
| 233 |
+
"rule_name": "60 DPD history",
|
| 234 |
+
"rule_type": RuleType.NON_STP,
|
| 235 |
+
"field_name": "dpd60Count",
|
| 236 |
+
"operator": ">",
|
| 237 |
+
"field_value": "0",
|
| 238 |
+
"decision_action": DecisionAction.UW,
|
| 239 |
+
"reason_code": "DPD_60_HISTORY",
|
| 240 |
+
"priority": 201,
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"rule_code": "DPD_90_UW",
|
| 244 |
+
"rule_name": "90 DPD history",
|
| 245 |
+
"rule_type": RuleType.NON_STP,
|
| 246 |
+
"field_name": "dpd90Count",
|
| 247 |
+
"operator": ">",
|
| 248 |
+
"field_value": "0",
|
| 249 |
+
"decision_action": DecisionAction.UW,
|
| 250 |
+
"reason_code": "DPD_90_HISTORY",
|
| 251 |
+
"priority": 202,
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"rule_code": "ENQUIRY_HIGH_UW",
|
| 255 |
+
"rule_name": "High enquiry burst",
|
| 256 |
+
"rule_type": RuleType.NON_STP,
|
| 257 |
+
"field_name": "creditEnquiries6M",
|
| 258 |
+
"operator": ">",
|
| 259 |
+
"field_value": "5",
|
| 260 |
+
"decision_action": DecisionAction.UW,
|
| 261 |
+
"reason_code": "HIGH_ENQUIRY_BURST",
|
| 262 |
+
"priority": 203,
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"rule_code": "FOIR_HIGH_UW",
|
| 266 |
+
"rule_name": "High FOIR underwriting",
|
| 267 |
+
"rule_type": RuleType.NON_STP,
|
| 268 |
+
"field_name": "foir",
|
| 269 |
+
"operator": ">",
|
| 270 |
+
"field_value": "50",
|
| 271 |
+
"decision_action": DecisionAction.UW,
|
| 272 |
+
"reason_code": "HIGH_FOIR",
|
| 273 |
+
"priority": 204,
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"rule_code": "UNSECURED_LOANS_UW",
|
| 277 |
+
"rule_name": "Multiple active unsecured loans",
|
| 278 |
+
"rule_type": RuleType.NON_STP,
|
| 279 |
+
"field_name": "activeUnsecuredLoans",
|
| 280 |
+
"operator": ">",
|
| 281 |
+
"field_value": "1",
|
| 282 |
+
"decision_action": DecisionAction.UW,
|
| 283 |
+
"reason_code": "MULTIPLE_UNSECURED_LOANS",
|
| 284 |
+
"priority": 205,
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"rule_code": "FACE_MATCH_LOW_UW",
|
| 288 |
+
"rule_name": "Low face match",
|
| 289 |
+
"rule_type": RuleType.NON_STP,
|
| 290 |
+
"field_name": "faceMatchScore",
|
| 291 |
+
"operator": "<",
|
| 292 |
+
"field_value": "75",
|
| 293 |
+
"decision_action": DecisionAction.UW,
|
| 294 |
+
"reason_code": "LOW_FACE_MATCH",
|
| 295 |
+
"priority": 206,
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
"rule_code": "STP_CIBIL",
|
| 299 |
+
"rule_name": "STP CIBIL threshold",
|
| 300 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 301 |
+
"field_name": "cibilScore",
|
| 302 |
+
"operator": ">=",
|
| 303 |
+
"field_value": "750",
|
| 304 |
+
"decision_action": DecisionAction.STP,
|
| 305 |
+
"reason_code": "STP_CIBIL_OK",
|
| 306 |
+
"priority": 300,
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"rule_code": "STP_NO_WRITEOFF",
|
| 310 |
+
"rule_name": "STP no written off",
|
| 311 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 312 |
+
"field_name": "writtenOffAccounts",
|
| 313 |
+
"operator": "=",
|
| 314 |
+
"field_value": "0",
|
| 315 |
+
"decision_action": DecisionAction.STP,
|
| 316 |
+
"reason_code": "STP_NO_WRITEOFF",
|
| 317 |
+
"priority": 301,
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"rule_code": "STP_NO_SETTLEMENT",
|
| 321 |
+
"rule_name": "STP no settlement",
|
| 322 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 323 |
+
"field_name": "settledAccounts",
|
| 324 |
+
"operator": "=",
|
| 325 |
+
"field_value": "0",
|
| 326 |
+
"decision_action": DecisionAction.STP,
|
| 327 |
+
"reason_code": "STP_NO_SETTLEMENT",
|
| 328 |
+
"priority": 302,
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"rule_code": "STP_NO_OVERDUE",
|
| 332 |
+
"rule_name": "STP no overdue",
|
| 333 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 334 |
+
"field_name": "overdueAmount",
|
| 335 |
+
"operator": "=",
|
| 336 |
+
"field_value": "0",
|
| 337 |
+
"decision_action": DecisionAction.STP,
|
| 338 |
+
"reason_code": "STP_NO_OVERDUE",
|
| 339 |
+
"priority": 303,
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"rule_code": "STP_MAX_DPD",
|
| 343 |
+
"rule_name": "STP max DPD threshold",
|
| 344 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 345 |
+
"field_name": "maxDPD",
|
| 346 |
+
"operator": "<=",
|
| 347 |
+
"field_value": "30",
|
| 348 |
+
"decision_action": DecisionAction.STP,
|
| 349 |
+
"reason_code": "STP_DPD_OK",
|
| 350 |
+
"priority": 304,
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"rule_code": "STP_FOIR",
|
| 354 |
+
"rule_name": "STP FOIR threshold",
|
| 355 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 356 |
+
"field_name": "foir",
|
| 357 |
+
"operator": "<=",
|
| 358 |
+
"field_value": "40",
|
| 359 |
+
"decision_action": DecisionAction.STP,
|
| 360 |
+
"reason_code": "STP_FOIR_OK",
|
| 361 |
+
"priority": 305,
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"rule_code": "STP_ENQUIRY",
|
| 365 |
+
"rule_name": "STP enquiry threshold",
|
| 366 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 367 |
+
"field_name": "creditEnquiries6M",
|
| 368 |
+
"operator": "<=",
|
| 369 |
+
"field_value": "3",
|
| 370 |
+
"decision_action": DecisionAction.STP,
|
| 371 |
+
"reason_code": "STP_ENQUIRY_OK",
|
| 372 |
+
"priority": 306,
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"rule_code": "STP_FACE_MATCH",
|
| 376 |
+
"rule_name": "STP face match threshold",
|
| 377 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 378 |
+
"field_name": "faceMatchScore",
|
| 379 |
+
"operator": ">=",
|
| 380 |
+
"field_value": "85",
|
| 381 |
+
"decision_action": DecisionAction.STP,
|
| 382 |
+
"reason_code": "STP_FACE_MATCH_OK",
|
| 383 |
+
"priority": 307,
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
"rule_code": "STP_KYC",
|
| 387 |
+
"rule_name": "STP KYC verified",
|
| 388 |
+
"rule_type": RuleType.STP_ELIGIBILITY,
|
| 389 |
+
"field_name": "kycVerified",
|
| 390 |
+
"operator": "=",
|
| 391 |
+
"field_value": "true",
|
| 392 |
+
"decision_action": DecisionAction.STP,
|
| 393 |
+
"reason_code": "STP_KYC_OK",
|
| 394 |
+
"priority": 308,
|
| 395 |
+
},
|
| 396 |
+
]
|
| 397 |
+
|
| 398 |
+
SUPPORTED_PRODUCT_TYPES = [
|
| 399 |
+
"TWO_WHEELER_LOAN",
|
| 400 |
+
"NEW_CAR_LOAN",
|
| 401 |
+
"USED_CAR_LOAN",
|
| 402 |
+
"NEW_COMMERCIAL_VEHICLE_LOAN",
|
| 403 |
+
"USED_COMMERCIAL_VEHICLE_LOAN",
|
| 404 |
+
"NEW_TRACTOR_LOAN",
|
| 405 |
+
"USED_TRACTOR_LOAN",
|
| 406 |
+
]
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def seed_default_rules(
|
| 410 |
+
db: Session, tenant_id: str = "default", product_type: str = "TWO_WHEELER_LOAN"
|
| 411 |
+
) -> int:
|
| 412 |
+
inserted = 0
|
| 413 |
+
for item in DEFAULT_RULES:
|
| 414 |
+
exists = db.scalar(
|
| 415 |
+
select(RuleMaster.id).where(
|
| 416 |
+
RuleMaster.tenant_id == tenant_id,
|
| 417 |
+
RuleMaster.product_type == product_type,
|
| 418 |
+
RuleMaster.rule_code == item["rule_code"],
|
| 419 |
+
RuleMaster.version == 1,
|
| 420 |
+
RuleMaster.dealer_id.is_(None),
|
| 421 |
+
)
|
| 422 |
+
)
|
| 423 |
+
if exists:
|
| 424 |
+
continue
|
| 425 |
+
db.add(
|
| 426 |
+
RuleMaster(
|
| 427 |
+
tenant_id=tenant_id,
|
| 428 |
+
product_type=product_type,
|
| 429 |
+
dealer_id=None,
|
| 430 |
+
version=1,
|
| 431 |
+
is_active=True,
|
| 432 |
+
created_by="seed",
|
| 433 |
+
**item,
|
| 434 |
+
)
|
| 435 |
+
)
|
| 436 |
+
inserted += 1
|
| 437 |
+
return inserted
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def seed_all_product_rules(db: Session, tenant_id: str = "default") -> int:
|
| 441 |
+
inserted = 0
|
| 442 |
+
for product_type in SUPPORTED_PRODUCT_TYPES:
|
| 443 |
+
inserted += seed_default_rules(db, tenant_id=tenant_id, product_type=product_type)
|
| 444 |
+
return inserted
|
session.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Generator
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import create_engine
|
| 4 |
+
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
| 5 |
+
|
| 6 |
+
from app.core.config import get_settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Base(DeclarativeBase):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
| 15 |
+
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
|
| 16 |
+
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_db() -> Generator[Session, None, None]:
|
| 20 |
+
db = SessionLocal()
|
| 21 |
+
try:
|
| 22 |
+
yield db
|
| 23 |
+
finally:
|
| 24 |
+
db.close()
|
| 25 |
+
|
ui.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
from fastapi.responses import FileResponse
|
| 5 |
+
|
| 6 |
+
router = APIRouter(tags=["UI"])
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/", include_in_schema=False)
|
| 10 |
+
def scorecard_console() -> FileResponse:
|
| 11 |
+
return FileResponse(Path("app/static/index.html"))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/generate-scorecard", include_in_schema=False)
|
| 15 |
+
def generate_scorecard_console() -> FileResponse:
|
| 16 |
+
return FileResponse(Path("app/static/index.html"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/simulate-scorecard", include_in_schema=False)
|
| 20 |
+
def simulate_scorecard_console() -> FileResponse:
|
| 21 |
+
return FileResponse(Path("app/static/index.html"))
|