feat(akses): batasi teknisi & kolektor sesuai wilayah tugas (port dari upstream)
- techService: getTechAreaWhere + canTechAccessCustomer, filter pool/stats - techPortal: filter peta, ODP, daftar perangkat; guard perangkat per wilayah; area pelanggan baru dipaksa sesuai wilayah - customerService: getCollectorCustomerWhere + canCollectorAccessCustomer (multi-area) - collectorPortal: daftar tagihan pakai helper; tolak bayar/cetak/kirim PDF di luar wilayah - customerAPI: dashboard & pay-bill kolektor dibatasi wilayah - tests/unit/area-access.test.js: self-check logika area
This commit is contained in:
@@ -71,6 +71,59 @@ function calculateExpiredAt(startDateStr, durationDays = 30) {
|
||||
return `${y}-${m}-${day} 23:59:59`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batas wilayah tugas kolektor (dukung multi-area dipisah koma).
|
||||
* area kosong / "semua" / "all" = tanpa batas wilayah.
|
||||
*/
|
||||
function getCollectorCustomerWhere(collector, tableAlias = 'c') {
|
||||
let col = collector;
|
||||
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||
}
|
||||
const colId = Number(col?.id || 0);
|
||||
const areas = String(col?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
|
||||
if (areas.length > 0) {
|
||||
const placeholders = areas.map(() => '?').join(', ');
|
||||
return {
|
||||
where: `(LOWER(TRIM(${tableAlias}.area)) IN (${placeholders}) AND (${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0))`,
|
||||
params: [...areas, colId],
|
||||
isRestricted: true,
|
||||
areas,
|
||||
};
|
||||
}
|
||||
return {
|
||||
where: `(${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0)`,
|
||||
params: [colId],
|
||||
isRestricted: false,
|
||||
areas: [],
|
||||
};
|
||||
}
|
||||
|
||||
function canCollectorAccessCustomer(collector, customer) {
|
||||
if (!collector || !customer) return false;
|
||||
let col = collector;
|
||||
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||
}
|
||||
const colId = Number(col?.id || 0);
|
||||
const custColId = Number(customer?.collector_id || 0);
|
||||
if (custColId > 0 && custColId !== colId) return false;
|
||||
|
||||
const areas = String(col?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
if (areas.length > 0) {
|
||||
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||
return !!custArea && areas.includes(custArea);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
|
||||
const now = getCurrentDateInTimezone();
|
||||
const month = now.getMonth() + 1;
|
||||
@@ -955,7 +1008,7 @@ Dihapus: ${removed.join(', ') || '-'}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllCustomers, getAllCustomerAreas, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
||||
getAllCustomers, getAllCustomerAreas, getCollectorCustomerWhere, canCollectorAccessCustomer, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
||||
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
|
||||
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
|
||||
resetPromoCyclesUsed, getEffectiveRouterId
|
||||
|
||||
@@ -9,20 +9,56 @@ function getTechById(id) {
|
||||
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batas wilayah tugas teknisi.
|
||||
* area kosong / "semua" / "all" = tanpa batas (semua wilayah).
|
||||
*/
|
||||
function getTechAreaWhere(technician, tableAlias = 'c') {
|
||||
let tech = technician;
|
||||
if (typeof technician === 'number' || typeof technician === 'string') {
|
||||
tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(Number(technician));
|
||||
}
|
||||
const areas = String(tech?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
|
||||
if (areas.length > 0) {
|
||||
const placeholders = areas.map(() => '?').join(', ');
|
||||
return {
|
||||
where: `LOWER(TRIM(${tableAlias}.area)) IN (${placeholders})`,
|
||||
params: areas,
|
||||
isRestricted: true,
|
||||
areas,
|
||||
};
|
||||
}
|
||||
return { where: '1=1', params: [], isRestricted: false, areas: [] };
|
||||
}
|
||||
|
||||
function canTechAccessCustomer(technician, customer) {
|
||||
if (!technician) return false;
|
||||
const filter = getTechAreaWhere(technician);
|
||||
if (!filter.isRestricted) return true;
|
||||
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||
return !!custArea && filter.areas.includes(custArea);
|
||||
}
|
||||
|
||||
function getTechStats(techId) {
|
||||
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
|
||||
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
|
||||
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
|
||||
|
||||
// Ambil open tickets yang belum ada teknisinya atau di-assign ke teknisi ini
|
||||
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
||||
|
||||
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||
const open = filter && filter.isRestricted
|
||||
? db.prepare(`SELECT COUNT(*) as count FROM tickets t JOIN customers c ON t.customer_id = c.id WHERE t.status = 'open' AND ${filter.where}`).get(...filter.params).count
|
||||
: db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
||||
|
||||
return { total, open, inProgress, resolved };
|
||||
}
|
||||
|
||||
function getAssignedTickets(techId) {
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.technician_id = ? AND t.status != 'resolved'
|
||||
@@ -30,19 +66,21 @@ function getAssignedTickets(techId) {
|
||||
`).all(techId);
|
||||
}
|
||||
|
||||
function getOpenTickets() {
|
||||
function getOpenTickets(techId = null) {
|
||||
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||
const areaWhere = filter && filter.isRestricted ? `AND ${filter.where}` : '';
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.status = 'open'
|
||||
WHERE t.status = 'open' ${areaWhere}
|
||||
ORDER BY t.created_at DESC
|
||||
`).all();
|
||||
`).all(...(filter && filter.isRestricted ? filter.params : []));
|
||||
}
|
||||
|
||||
function getResolvedTickets(techId) {
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.technician_id = ? AND t.status = 'resolved'
|
||||
@@ -52,11 +90,27 @@ function getResolvedTickets(techId) {
|
||||
|
||||
function takeTicket(ticketId, techId) {
|
||||
let validTechId = Number(techId);
|
||||
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId);
|
||||
if (!exists) {
|
||||
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
||||
if (fallback) validTechId = fallback.id;
|
||||
let tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(validTechId);
|
||||
if (!tech) {
|
||||
const fallback = db.prepare('SELECT id, area FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
||||
if (!fallback) throw new Error('Teknisi tidak ditemukan.');
|
||||
validTechId = fallback.id;
|
||||
tech = fallback;
|
||||
}
|
||||
|
||||
const ticket = db.prepare(`
|
||||
SELECT t.id, t.customer_id, t.status, c.area as customer_area
|
||||
FROM tickets t
|
||||
LEFT JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.id = ?
|
||||
`).get(ticketId);
|
||||
|
||||
if (!ticket) throw new Error('Tiket tidak ditemukan.');
|
||||
if (ticket.status !== 'open') throw new Error('Tiket sudah diambil oleh teknisi lain atau sudah selesai.');
|
||||
if (ticket.customer_id && !canTechAccessCustomer(tech, { area: ticket.customer_area })) {
|
||||
throw new Error(`Tiket ini berada di wilayah "${ticket.customer_area || 'Tanpa Area'}", di luar wilayah tugas Anda (${tech.area}).`);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
|
||||
return validTechId;
|
||||
}
|
||||
@@ -102,6 +156,8 @@ function createTechnician(data) {
|
||||
module.exports = {
|
||||
authenticate,
|
||||
getTechById,
|
||||
getTechAreaWhere,
|
||||
canTechAccessCustomer,
|
||||
getTechStats,
|
||||
getAssignedTickets,
|
||||
getOpenTickets,
|
||||
|
||||
Reference in New Issue
Block a user