Add bulk client registration addition
This commit is contained in:
@@ -0,0 +1,373 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
||||||
|
{% if 'partner' in _uiux_partner_role_text %}
|
||||||
|
{% include "ui/templates/components/partner_navigation_v2.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">Bulk Registration Addition</h1>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
Add the same registration type to multiple clients while entering each client’s unique registration number.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/registrations"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
Registrations Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if created or skipped or invalid %}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Created</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-emerald-700">{{ created }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Duplicates Skipped</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-amber-700">{{ skipped }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Invalid / Missing</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-rose-700">{{ invalid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/registrations/bulk" id="bulk-registration-form" class="space-y-5">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">Common Registration Settings</h2>
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Registration Type *</label>
|
||||||
|
<select name="registration_type_id" required
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select registration type</option>
|
||||||
|
{% for row in types %}
|
||||||
|
<option value="{{ row.id }}">{{ row.name }} ({{ row.code }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Default Scope</label>
|
||||||
|
<select name="default_scope"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="client">Client / Entity</option>
|
||||||
|
<option value="primary_business">Primary Business Unit</option>
|
||||||
|
<option value="primary_branch">Primary Client Branch</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Per-client Business Unit and Branch selections in the table override this default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Lifecycle Rule</label>
|
||||||
|
<select name="lifecycle_rule_id"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Automatic rule</option>
|
||||||
|
{% for rule, registration_type in rules %}
|
||||||
|
<option value="{{ rule.id }}">
|
||||||
|
{{ registration_type.name }} — {{ rule.cycle_type|replace('_', ' ')|title }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Issue Date</label>
|
||||||
|
<input type="date" name="issue_date"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Effective From</label>
|
||||||
|
<input type="date" name="effective_from"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Valid Until</label>
|
||||||
|
<input type="date" name="valid_until"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Next Update / Confirmation</label>
|
||||||
|
<input type="date" name="next_action_date"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Jurisdiction</label>
|
||||||
|
<input name="jurisdiction"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">State Code</label>
|
||||||
|
<input name="state_code" maxlength="10"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Responsible Party</label>
|
||||||
|
<select name="responsible_party"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="firm">Firm</option>
|
||||||
|
<option value="client">Client</option>
|
||||||
|
<option value="consultant">Consultant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Assigned Consultant</label>
|
||||||
|
<select name="assigned_consultant_id"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">No consultant</option>
|
||||||
|
{% for row in consultants %}
|
||||||
|
<option value="{{ row.id }}">
|
||||||
|
{{ row.display_name or row.name or ('Consultant #' ~ row.id) }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="auto_create_task" checked
|
||||||
|
class="rounded border-slate-300">
|
||||||
|
Auto-create renewal work
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 xl:col-span-3">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Common Notes</label>
|
||||||
|
<textarea name="notes" rows="2"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3 border-b border-slate-200 p-4">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Search Clients
|
||||||
|
</label>
|
||||||
|
<input type="search" id="client-search"
|
||||||
|
placeholder="Code, client name, PAN, GSTIN or trade name"
|
||||||
|
class="w-96 max-w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="clear-search"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm text-slate-700">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-600">
|
||||||
|
<span id="selected-count" class="font-semibold text-slate-900">0</span> clients selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[36rem] overflow-auto">
|
||||||
|
<table class="min-w-[1500px] divide-y divide-slate-200">
|
||||||
|
<thead class="sticky top-0 z-10 bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 px-3 py-3 text-left">
|
||||||
|
<input type="checkbox" id="select-all-visible" class="rounded border-slate-300">
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Client</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">PAN / Existing GSTIN</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Registration Number *</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Business Unit</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Client Branch</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Legal Name</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">Trade / Unit Name</th>
|
||||||
|
<th class="px-3 py-3 text-left text-xs font-semibold uppercase text-slate-500">State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for client in clients %}
|
||||||
|
{% set client_businesses = businesses_by_client.get(client.id, []) %}
|
||||||
|
{% set client_branches = branches_by_client.get(client.id, []) %}
|
||||||
|
<tr class="client-row align-top"
|
||||||
|
data-search="{{ ((client.client_code or '') ~ ' ' ~ (client.client_name or '') ~ ' ' ~ (client.pan or '') ~ ' ' ~ (client.gstin or '') ~ ' ' ~ (client.trade_name or ''))|lower|e }}">
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<input type="checkbox" name="client_ids" value="{{ client.id }}"
|
||||||
|
class="client-checkbox rounded border-slate-300">
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-sm">
|
||||||
|
<div class="font-medium text-slate-900">{{ client.client_name }}</div>
|
||||||
|
<div class="text-xs text-slate-500">{{ client.client_code or '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-xs text-slate-600">
|
||||||
|
<div>PAN: {{ client.pan or '-' }}</div>
|
||||||
|
<div>GSTIN: {{ client.gstin or '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<input name="registration_number_{{ client.id }}"
|
||||||
|
placeholder="Unique number"
|
||||||
|
class="w-48 rounded-lg border border-slate-300 px-2 py-1.5 text-sm uppercase">
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<select name="business_unit_id_{{ client.id }}"
|
||||||
|
class="business-select w-52 rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
data-client="{{ client.id }}">
|
||||||
|
<option value="">Use default / Client level</option>
|
||||||
|
{% for row in client_businesses %}
|
||||||
|
<option value="{{ row.id }}">{{ row.business_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<select name="client_branch_id_{{ client.id }}"
|
||||||
|
class="branch-select w-52 rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
data-client="{{ client.id }}">
|
||||||
|
<option value="">Use default / No branch</option>
|
||||||
|
{% for row in client_branches %}
|
||||||
|
<option value="{{ row.id }}" data-business="{{ row.business_unit_id }}">
|
||||||
|
{{ row.branch_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<input name="legal_name_{{ client.id }}"
|
||||||
|
value="{{ client.client_name or '' }}"
|
||||||
|
class="w-52 rounded-lg border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<input name="trade_name_{{ client.id }}"
|
||||||
|
value="{{ client.trade_name or '' }}"
|
||||||
|
class="w-52 rounded-lg border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<input name="state_{{ client.id }}"
|
||||||
|
value="{{ client.state or '' }}"
|
||||||
|
class="w-40 rounded-lg border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
No active clients are available in the current Firm Branch.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
Rows without a registration number are treated as invalid. Existing registration numbers are skipped without changing their records.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/registrations"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm text-slate-700">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||||
|
Add Registrations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('bulk-registration-form');
|
||||||
|
const search = document.getElementById('client-search');
|
||||||
|
const clear = document.getElementById('clear-search');
|
||||||
|
const rows = Array.from(document.querySelectorAll('.client-row'));
|
||||||
|
const boxes = Array.from(document.querySelectorAll('.client-checkbox'));
|
||||||
|
const selectAll = document.getElementById('select-all-visible');
|
||||||
|
const count = document.getElementById('selected-count');
|
||||||
|
|
||||||
|
function visibleRows() {
|
||||||
|
return rows.filter(row => !row.classList.contains('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCount() {
|
||||||
|
count.textContent = String(boxes.filter(box => box.checked).length);
|
||||||
|
const visibleBoxes = visibleRows().map(row => row.querySelector('.client-checkbox'));
|
||||||
|
const checkedVisible = visibleBoxes.filter(box => box.checked).length;
|
||||||
|
selectAll.checked = visibleBoxes.length > 0 && checkedVisible === visibleBoxes.length;
|
||||||
|
selectAll.indeterminate = checkedVisible > 0 && checkedVisible < visibleBoxes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRows() {
|
||||||
|
const term = search.value.trim().toLowerCase();
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.classList.toggle('hidden', term && !row.dataset.search.includes(term));
|
||||||
|
});
|
||||||
|
refreshCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', filterRows);
|
||||||
|
clear.addEventListener('click', function () {
|
||||||
|
search.value = '';
|
||||||
|
filterRows();
|
||||||
|
search.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
boxes.forEach(box => box.addEventListener('change', refreshCount));
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
visibleRows().forEach(row => {
|
||||||
|
row.querySelector('.client-checkbox').checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
refreshCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.branch-select').forEach(branchSelect => {
|
||||||
|
branchSelect.addEventListener('change', function () {
|
||||||
|
const selected = branchSelect.options[branchSelect.selectedIndex];
|
||||||
|
const businessId = selected ? selected.dataset.business : '';
|
||||||
|
if (!businessId) return;
|
||||||
|
const businessSelect = document.querySelector(
|
||||||
|
`.business-select[data-client="${branchSelect.dataset.client}"]`
|
||||||
|
);
|
||||||
|
if (businessSelect) businessSelect.value = businessId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
const selected = boxes.filter(box => box.checked);
|
||||||
|
if (!selected.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
alert('Select at least one client.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = selected.filter(box => {
|
||||||
|
const number = document.querySelector(
|
||||||
|
`[name="registration_number_${box.value}"]`
|
||||||
|
);
|
||||||
|
return !number || !number.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
alert('Enter the registration number for every selected client.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Add registrations for ${selected.length} selected client${selected.length === 1 ? '' : 's'}?`)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshCount();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,11 +3,12 @@ from datetime import date
|
|||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
from app.core.security.session_auth import get_current_user
|
from app.core.security.session_auth import get_current_user
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.clients.models import Client
|
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||||
from app.modules.consultants.models import ConsultantProfile
|
from app.modules.consultants.models import ConsultantProfile
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
@@ -81,6 +82,319 @@ async def create_doc_template(request:Request,registration_type_id:int=Form(...)
|
|||||||
user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit")
|
user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit")
|
||||||
db.add(RegistrationDocumentTemplate(tenant_id=_tenant(request,user),registration_type_id=registration_type_id,action_type=action_type,document_name=document_name.strip(),description=description or None,mandatory=mandatory));db.commit();return RedirectResponse('/registrations/masters',303)
|
db.add(RegistrationDocumentTemplate(tenant_id=_tenant(request,user),registration_type_id=registration_type_id,action_type=action_type,document_name=document_name.strip(),description=description or None,mandatory=mandatory));db.commit();return RedirectResponse('/registrations/masters',303)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bulk", response_class=HTMLResponse)
|
||||||
|
def bulk_registration_page(
|
||||||
|
request: Request,
|
||||||
|
created: int = 0,
|
||||||
|
skipped: int = 0,
|
||||||
|
invalid: int = 0,
|
||||||
|
):
|
||||||
|
with CommonSessionLocal() as db:
|
||||||
|
user = _user(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
require_permission(db, user, "clients.edit")
|
||||||
|
seed_registration_types(db)
|
||||||
|
|
||||||
|
tenant_id = _tenant(request, user)
|
||||||
|
branch_id = _branch(request, user)
|
||||||
|
|
||||||
|
clients_query = select(Client).where(
|
||||||
|
Client.tenant_id == tenant_id,
|
||||||
|
Client.is_active.is_(True),
|
||||||
|
)
|
||||||
|
if branch_id:
|
||||||
|
clients_query = clients_query.where(Client.branch_id == branch_id)
|
||||||
|
|
||||||
|
clients = db.execute(
|
||||||
|
clients_query.order_by(Client.client_name, Client.client_code)
|
||||||
|
).scalars().all()
|
||||||
|
client_ids = [row.id for row in clients]
|
||||||
|
|
||||||
|
business_units = []
|
||||||
|
client_branches = []
|
||||||
|
if client_ids:
|
||||||
|
business_units = db.execute(
|
||||||
|
select(ClientBusinessUnit).where(
|
||||||
|
ClientBusinessUnit.tenant_id == tenant_id,
|
||||||
|
ClientBusinessUnit.client_id.in_(client_ids),
|
||||||
|
ClientBusinessUnit.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBusinessUnit.client_id,
|
||||||
|
ClientBusinessUnit.is_primary.desc(),
|
||||||
|
ClientBusinessUnit.business_name,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
client_branches = db.execute(
|
||||||
|
select(ClientBranch).where(
|
||||||
|
ClientBranch.tenant_id == tenant_id,
|
||||||
|
ClientBranch.client_id.in_(client_ids),
|
||||||
|
ClientBranch.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBranch.client_id,
|
||||||
|
ClientBranch.is_primary.desc(),
|
||||||
|
ClientBranch.branch_name,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
businesses_by_client: dict[int, list[ClientBusinessUnit]] = {}
|
||||||
|
for row in business_units:
|
||||||
|
businesses_by_client.setdefault(row.client_id, []).append(row)
|
||||||
|
|
||||||
|
branches_by_client: dict[int, list[ClientBranch]] = {}
|
||||||
|
for row in client_branches:
|
||||||
|
branches_by_client.setdefault(row.client_id, []).append(row)
|
||||||
|
|
||||||
|
types = db.execute(
|
||||||
|
select(RegistrationType).where(
|
||||||
|
RegistrationType.is_active.is_(True)
|
||||||
|
).order_by(RegistrationType.sort_order, RegistrationType.name)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
rules = db.execute(
|
||||||
|
select(RegistrationLifecycleRule, RegistrationType).join(
|
||||||
|
RegistrationType,
|
||||||
|
RegistrationType.id == RegistrationLifecycleRule.registration_type_id,
|
||||||
|
).where(
|
||||||
|
or_(
|
||||||
|
RegistrationLifecycleRule.tenant_id.is_(None),
|
||||||
|
RegistrationLifecycleRule.tenant_id == tenant_id,
|
||||||
|
),
|
||||||
|
RegistrationLifecycleRule.is_active.is_(True),
|
||||||
|
).order_by(RegistrationType.name, RegistrationLifecycleRule.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
consultants = db.execute(
|
||||||
|
select(ConsultantProfile).where(
|
||||||
|
ConsultantProfile.tenant_id == tenant_id,
|
||||||
|
ConsultantProfile.is_active.is_(True),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/registrations/templates/registrations/bulk.html",
|
||||||
|
_ctx(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
db,
|
||||||
|
title="Bulk Registration Addition",
|
||||||
|
clients=clients,
|
||||||
|
businesses_by_client=businesses_by_client,
|
||||||
|
branches_by_client=branches_by_client,
|
||||||
|
types=types,
|
||||||
|
rules=rules,
|
||||||
|
consultants=consultants,
|
||||||
|
created=created,
|
||||||
|
skipped=skipped,
|
||||||
|
invalid=invalid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
async def bulk_registration_submit(
|
||||||
|
request: Request,
|
||||||
|
client_ids: list[int] = Form([]),
|
||||||
|
registration_type_id: int = Form(...),
|
||||||
|
default_scope: str = Form("client"),
|
||||||
|
jurisdiction: str = Form(""),
|
||||||
|
state_code: str = Form(""),
|
||||||
|
issue_date: str = Form(""),
|
||||||
|
effective_from: str = Form(""),
|
||||||
|
valid_until: str = Form(""),
|
||||||
|
next_action_date: str = Form(""),
|
||||||
|
lifecycle_rule_id: str = Form(""),
|
||||||
|
responsible_party: str = Form("firm"),
|
||||||
|
assigned_consultant_id: str = Form(""),
|
||||||
|
auto_create_task: bool = Form(False),
|
||||||
|
notes: str = Form(""),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
with CommonSessionLocal() as db:
|
||||||
|
user = _user(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
require_permission(db, user, "clients.edit")
|
||||||
|
|
||||||
|
tenant_id = _tenant(request, user)
|
||||||
|
branch_id = _branch(request, user)
|
||||||
|
|
||||||
|
registration_type = db.get(RegistrationType, registration_type_id)
|
||||||
|
if not registration_type or not registration_type.is_active:
|
||||||
|
return RedirectResponse("/registrations/bulk?invalid=1", 303)
|
||||||
|
|
||||||
|
selected_ids = list(dict.fromkeys(int(value) for value in client_ids))
|
||||||
|
if not selected_ids:
|
||||||
|
return RedirectResponse("/registrations/bulk?invalid=1", 303)
|
||||||
|
|
||||||
|
clients_query = select(Client).where(
|
||||||
|
Client.tenant_id == tenant_id,
|
||||||
|
Client.id.in_(selected_ids),
|
||||||
|
Client.is_active.is_(True),
|
||||||
|
)
|
||||||
|
if branch_id:
|
||||||
|
clients_query = clients_query.where(Client.branch_id == branch_id)
|
||||||
|
|
||||||
|
permitted_clients = db.execute(clients_query).scalars().all()
|
||||||
|
clients_by_id = {row.id: row for row in permitted_clients}
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
invalid = 0
|
||||||
|
|
||||||
|
for client_id in selected_ids:
|
||||||
|
client = clients_by_id.get(client_id)
|
||||||
|
if not client:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
registration_number = (
|
||||||
|
str(form.get(f"registration_number_{client_id}", ""))
|
||||||
|
.strip()
|
||||||
|
.upper()
|
||||||
|
)
|
||||||
|
if not registration_number:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
business_unit_id = None
|
||||||
|
client_branch_id = None
|
||||||
|
|
||||||
|
raw_business_id = str(
|
||||||
|
form.get(f"business_unit_id_{client_id}", "")
|
||||||
|
).strip()
|
||||||
|
raw_branch_id = str(
|
||||||
|
form.get(f"client_branch_id_{client_id}", "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if raw_business_id:
|
||||||
|
business = db.get(ClientBusinessUnit, int(raw_business_id))
|
||||||
|
if (
|
||||||
|
not business
|
||||||
|
or business.tenant_id != tenant_id
|
||||||
|
or business.client_id != client.id
|
||||||
|
or not business.is_active
|
||||||
|
):
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
business_unit_id = business.id
|
||||||
|
|
||||||
|
if raw_branch_id:
|
||||||
|
client_branch = db.get(ClientBranch, int(raw_branch_id))
|
||||||
|
if (
|
||||||
|
not client_branch
|
||||||
|
or client_branch.tenant_id != tenant_id
|
||||||
|
or client_branch.client_id != client.id
|
||||||
|
or not client_branch.is_active
|
||||||
|
):
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
client_branch_id = client_branch.id
|
||||||
|
business_unit_id = client_branch.business_unit_id
|
||||||
|
|
||||||
|
if default_scope == "primary_business" and not business_unit_id:
|
||||||
|
business = db.execute(
|
||||||
|
select(ClientBusinessUnit).where(
|
||||||
|
ClientBusinessUnit.tenant_id == tenant_id,
|
||||||
|
ClientBusinessUnit.client_id == client.id,
|
||||||
|
ClientBusinessUnit.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBusinessUnit.is_primary.desc(),
|
||||||
|
ClientBusinessUnit.id,
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if not business:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
business_unit_id = business.id
|
||||||
|
|
||||||
|
if default_scope == "primary_branch" and not client_branch_id:
|
||||||
|
client_branch = db.execute(
|
||||||
|
select(ClientBranch).where(
|
||||||
|
ClientBranch.tenant_id == tenant_id,
|
||||||
|
ClientBranch.client_id == client.id,
|
||||||
|
ClientBranch.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBranch.is_primary.desc(),
|
||||||
|
ClientBranch.id,
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if not client_branch:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
client_branch_id = client_branch.id
|
||||||
|
business_unit_id = client_branch.business_unit_id
|
||||||
|
|
||||||
|
existing = db.execute(
|
||||||
|
select(ClientRegistration).where(
|
||||||
|
ClientRegistration.tenant_id == tenant_id,
|
||||||
|
ClientRegistration.registration_type_id == registration_type.id,
|
||||||
|
ClientRegistration.registration_number == registration_number,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
legal_name = str(form.get(f"legal_name_{client_id}", "")).strip()
|
||||||
|
trade_name = str(form.get(f"trade_name_{client_id}", "")).strip()
|
||||||
|
row_state = str(form.get(f"state_{client_id}", "")).strip()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ClientRegistration(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=client.branch_id,
|
||||||
|
client_id=client.id,
|
||||||
|
business_unit_id=business_unit_id,
|
||||||
|
client_branch_id=client_branch_id,
|
||||||
|
registration_type_id=registration_type.id,
|
||||||
|
registration_number=registration_number,
|
||||||
|
legal_name=legal_name or client.client_name,
|
||||||
|
trade_name=trade_name or client.trade_name or None,
|
||||||
|
state=row_state or client.state or None,
|
||||||
|
jurisdiction=jurisdiction.strip() or row_state or client.state or None,
|
||||||
|
state_code=state_code.strip().upper() or None,
|
||||||
|
issue_date=_d(issue_date),
|
||||||
|
effective_from=_d(effective_from),
|
||||||
|
valid_until=_d(valid_until),
|
||||||
|
next_action_date=_d(next_action_date),
|
||||||
|
lifecycle_rule_id=int(lifecycle_rule_id) if lifecycle_rule_id else None,
|
||||||
|
responsible_party=responsible_party,
|
||||||
|
assigned_user_id=client.partner_id,
|
||||||
|
assigned_consultant_id=(
|
||||||
|
int(assigned_consultant_id)
|
||||||
|
if assigned_consultant_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
auto_create_task=auto_create_task,
|
||||||
|
status="active",
|
||||||
|
notes=notes.strip() or None,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
run_registration_lifecycle_once(db)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/registrations/bulk?created={created}&skipped={skipped}&invalid={invalid}",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/clients/{client_id}",response_class=HTMLResponse)
|
@router.get("/clients/{client_id}",response_class=HTMLResponse)
|
||||||
def client_register(request:Request,client_id:int):
|
def client_register(request:Request,client_id:int):
|
||||||
with CommonSessionLocal() as db:
|
with CommonSessionLocal() as db:
|
||||||
|
|||||||
@@ -70,7 +70,12 @@
|
|||||||
{
|
{
|
||||||
'label': 'Registrations & Renewals',
|
'label': 'Registrations & Renewals',
|
||||||
'url': '/registrations',
|
'url': '/registrations',
|
||||||
'active': _partner_path == '/registrations' or (_partner_path.startswith('/registrations/') and not _partner_path.startswith('/registrations/masters'))
|
'active': _partner_path == '/registrations' or (_partner_path.startswith('/registrations/') and not _partner_path.startswith('/registrations/masters') and not _partner_path.startswith('/registrations/bulk'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Bulk Registration Addition',
|
||||||
|
'url': '/registrations/bulk',
|
||||||
|
'active': _partner_path.startswith('/registrations/bulk')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Registration Masters',
|
'label': 'Registration Masters',
|
||||||
|
|||||||
Reference in New Issue
Block a user