Updated Mult-Company Support and added Tax ID Fields
This commit is contained in:
18
server/db.js
18
server/db.js
@@ -119,6 +119,9 @@ function initialize() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
legal_name TEXT,
|
legal_name TEXT,
|
||||||
tax_id TEXT,
|
tax_id TEXT,
|
||||||
|
federal_tax_id TEXT,
|
||||||
|
state_tax_id TEXT,
|
||||||
|
local_tax_id TEXT,
|
||||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||||
base_currency TEXT NOT NULL DEFAULT 'USD',
|
base_currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
@@ -140,6 +143,7 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS employees (
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
workday_worker_id TEXT UNIQUE,
|
workday_worker_id TEXT UNIQUE,
|
||||||
employee_number TEXT,
|
employee_number TEXT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -781,12 +785,18 @@ function migrate() {
|
|||||||
ensureColumn('entities', 'status', "TEXT NOT NULL DEFAULT 'active'");
|
ensureColumn('entities', 'status', "TEXT NOT NULL DEFAULT 'active'");
|
||||||
ensureColumn('entities', 'disposed_at', 'TEXT');
|
ensureColumn('entities', 'disposed_at', 'TEXT');
|
||||||
ensureColumn('entities', 'disposed_reason', 'TEXT');
|
ensureColumn('entities', 'disposed_reason', 'TEXT');
|
||||||
|
// Separate federal / state / optional local tax ids; the legacy single tax_id becomes the federal one.
|
||||||
|
ensureColumn('entities', 'federal_tax_id', 'TEXT');
|
||||||
|
ensureColumn('entities', 'state_tax_id', 'TEXT');
|
||||||
|
ensureColumn('entities', 'local_tax_id', 'TEXT');
|
||||||
|
run('UPDATE entities SET federal_tax_id = tax_id WHERE federal_tax_id IS NULL AND tax_id IS NOT NULL');
|
||||||
ensureColumn('pm_plans', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
ensureColumn('pm_plans', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
ensureColumn('alerts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
ensureColumn('alerts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
rebuildBooksPerCompany();
|
rebuildBooksPerCompany();
|
||||||
// Phase 2: scope reference data, contacts, templates, saved reports per company.
|
// Phase 2: scope reference data, contacts, templates, saved reports per company.
|
||||||
ensureColumn('contacts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
ensureColumn('contacts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
ensureColumn('saved_reports', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
ensureColumn('saved_reports', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
|
ensureColumn('employees', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
rebuildReferenceValuesPerCompany();
|
rebuildReferenceValuesPerCompany();
|
||||||
rebuildAssetTemplatesPerCompany();
|
rebuildAssetTemplatesPerCompany();
|
||||||
rebuildPartsPerCompany();
|
rebuildPartsPerCompany();
|
||||||
@@ -917,7 +927,7 @@ function backfillCompanyScope() {
|
|||||||
if (!def) return;
|
if (!def) return;
|
||||||
// OR IGNORE so a pre-existing duplicate (e.g. two templates with the same name) can't break the
|
// OR IGNORE so a pre-existing duplicate (e.g. two templates with the same name) can't break the
|
||||||
// new per-company UNIQUE constraints — the duplicate is left unscoped rather than crashing init.
|
// new per-company UNIQUE constraints — the duplicate is left unscoped rather than crashing init.
|
||||||
for (const table of ['assets', 'books', 'pm_plans', 'alerts', 'reference_values', 'contacts', 'asset_templates', 'saved_reports', 'parts']) {
|
for (const table of ['assets', 'books', 'pm_plans', 'alerts', 'reference_values', 'contacts', 'asset_templates', 'saved_reports', 'parts', 'employees']) {
|
||||||
run(`UPDATE OR IGNORE ${table} SET entity_id = ? WHERE entity_id IS NULL`, [def.id]);
|
run(`UPDATE OR IGNORE ${table} SET entity_id = ? WHERE entity_id IS NULL`, [def.id]);
|
||||||
}
|
}
|
||||||
// Preserve "everyone sees all" on upgrade: grant each existing user every existing company,
|
// Preserve "everyone sees all" on upgrade: grant each existing user every existing company,
|
||||||
@@ -1142,9 +1152,9 @@ function seed() {
|
|||||||
|
|
||||||
run(
|
run(
|
||||||
`INSERT OR IGNORE INTO employees (
|
`INSERT OR IGNORE INTO employees (
|
||||||
employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
|
entity_id, employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'DepreCore Admin', 'active', 'seed', createdAt, createdAt]
|
[refEntity ? refEntity.id : null, 'E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'DepreCore Admin', 'active', 'seed', createdAt, createdAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
run(
|
run(
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ const {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/employees', (req, res) => {
|
router.get('/employees', (req, res) => {
|
||||||
res.json({ employees: listEmployees(req.query) });
|
res.json({ employees: listEmployees(req.query, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
||||||
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
|
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assignments', (req, res) => {
|
router.get('/assignments', (req, res) => {
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ function assignmentFromRow(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listEmployees(query = {}) {
|
function listEmployees(query = {}, companyId) {
|
||||||
return all(`
|
return all(`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM employees
|
FROM employees
|
||||||
WHERE (? IS NULL OR status = ?)
|
WHERE (? IS NULL OR entity_id = ?)
|
||||||
|
AND (? IS NULL OR status = ?)
|
||||||
AND (? IS NULL OR name LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR employee_number LIKE '%' || ? || '%')
|
AND (? IS NULL OR name LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR employee_number LIKE '%' || ? || '%')
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
`, [
|
`, [
|
||||||
|
companyId || null, companyId || null,
|
||||||
query.status || null,
|
query.status || null,
|
||||||
query.status || null,
|
query.status || null,
|
||||||
query.search || null,
|
query.search || null,
|
||||||
@@ -34,12 +36,14 @@ function listEmployees(query = {}) {
|
|||||||
]).map(employeeFromRow);
|
]).map(employeeFromRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertEmployee(employee) {
|
// Manual creates use the active company; Workday sync (no companyId) lands in the default company.
|
||||||
|
function upsertEmployee(employee, companyId) {
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
|
const entityId = companyId || (one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1")?.id) || null;
|
||||||
const workdayId = employee.workday_worker_id || employee.workdayWorkerId || employee.workerId || null;
|
const workdayId = employee.workday_worker_id || employee.workdayWorkerId || employee.workerId || null;
|
||||||
const existing = workdayId
|
const existing = workdayId
|
||||||
? one('SELECT id FROM employees WHERE workday_worker_id = ?', [workdayId])
|
? one('SELECT id FROM employees WHERE workday_worker_id = ?', [workdayId])
|
||||||
: one('SELECT id FROM employees WHERE employee_number = ? OR lower(email) = lower(?)', [employee.employee_number || '', employee.email || '']);
|
: one('SELECT id FROM employees WHERE entity_id = ? AND (employee_number = ? OR lower(email) = lower(?))', [entityId, employee.employee_number || '', employee.email || '']);
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
workdayId,
|
workdayId,
|
||||||
@@ -79,10 +83,10 @@ function upsertEmployee(employee) {
|
|||||||
|
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO employees (
|
`INSERT INTO employees (
|
||||||
workday_worker_id, employee_number, name, email, department, location, manager_name,
|
entity_id, workday_worker_id, employee_number, name, email, department, location, manager_name,
|
||||||
status, source, source_payload, last_synced_at, created_at, updated_at
|
status, source, source_payload, last_synced_at, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[...values, timestamp]
|
[entityId, ...values, timestamp]
|
||||||
);
|
);
|
||||||
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,8 @@ function assignAsset(body, userId, companyId) {
|
|||||||
const asset = one('SELECT * FROM assets WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.asset_id, companyId || null, companyId || null]);
|
const asset = one('SELECT * FROM assets WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.asset_id, companyId || null, companyId || null]);
|
||||||
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
||||||
|
|
||||||
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
|
// Only an employee in the same company may be assigned (foreign ids resolve to no employee).
|
||||||
|
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.employee_id, companyId || null, companyId || null]) : null;
|
||||||
const department = body.department ?? employee?.department ?? asset.department ?? null;
|
const department = body.department ?? employee?.department ?? asset.department ?? null;
|
||||||
const location = body.location ?? employee?.location ?? asset.location ?? null;
|
const location = body.location ?? employee?.location ?? asset.location ?? null;
|
||||||
|
|
||||||
@@ -134,7 +139,7 @@ function assignAsset(body, userId, companyId) {
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
body.asset_id,
|
body.asset_id,
|
||||||
body.employee_id || null,
|
employee ? employee.id : null,
|
||||||
department,
|
department,
|
||||||
location,
|
location,
|
||||||
'assigned',
|
'assigned',
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ function createCompany(body, userId) {
|
|||||||
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
|
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
|
`INSERT INTO entities (name, legal_name, federal_tax_id, state_tax_id, local_tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||||
[name, body.legal_name || null, body.tax_id || null, Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts]
|
[
|
||||||
|
name, body.legal_name || null,
|
||||||
|
body.federal_tax_id || null, body.state_tax_id || null, body.local_tax_id || null,
|
||||||
|
Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts
|
||||||
|
]
|
||||||
);
|
);
|
||||||
// A new company starts with the standard book set + reference data (categories/locations/depts).
|
// A new company starts with the standard book set + reference data (categories/locations/depts).
|
||||||
seedBooksForCompany(result.lastInsertRowid);
|
seedBooksForCompany(result.lastInsertRowid);
|
||||||
@@ -36,11 +40,14 @@ function updateCompany(id, body, userId) {
|
|||||||
const before = getCompany(id);
|
const before = getCompany(id);
|
||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
run(
|
run(
|
||||||
`UPDATE entities SET name = ?, legal_name = ?, tax_id = ?, fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE entities SET name = ?, legal_name = ?, federal_tax_id = ?, state_tax_id = ?, local_tax_id = ?,
|
||||||
|
fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
|
||||||
[
|
[
|
||||||
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
|
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
|
||||||
body.legal_name !== undefined ? body.legal_name : before.legal_name,
|
body.legal_name !== undefined ? body.legal_name : before.legal_name,
|
||||||
body.tax_id !== undefined ? body.tax_id : before.tax_id,
|
body.federal_tax_id !== undefined ? body.federal_tax_id : before.federal_tax_id,
|
||||||
|
body.state_tax_id !== undefined ? body.state_tax_id : before.state_tax_id,
|
||||||
|
body.local_tax_id !== undefined ? body.local_tax_id : before.local_tax_id,
|
||||||
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
|
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
|
||||||
body.base_currency !== undefined ? body.base_currency : before.base_currency,
|
body.base_currency !== undefined ? body.base_currency : before.base_currency,
|
||||||
now(), id
|
now(), id
|
||||||
|
|||||||
@@ -1634,10 +1634,15 @@ async function main() {
|
|||||||
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
|
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
|
||||||
// Company A is the seeded default; create company B.
|
// Company A is the seeded default; create company B.
|
||||||
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
|
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
|
||||||
const companyB = (await request('/api/entities', {
|
const companyBEntity = (await request('/api/entities', {
|
||||||
method: 'POST', headers: auth, body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD' })
|
method: 'POST', headers: auth,
|
||||||
})).entity.id;
|
body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD', federal_tax_id: '12-3456789', state_tax_id: 'ST-001', local_tax_id: 'LOC-9' })
|
||||||
|
})).entity;
|
||||||
|
const companyB = companyBEntity.id;
|
||||||
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
|
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
|
||||||
|
if (companyBEntity.federal_tax_id !== '12-3456789' || companyBEntity.state_tax_id !== 'ST-001' || companyBEntity.local_tax_id !== 'LOC-9') {
|
||||||
|
throw new Error('Company federal/state/local tax ids did not persist');
|
||||||
|
}
|
||||||
|
|
||||||
// A new company auto-seeds its own book set (distinct ids from company A's).
|
// A new company auto-seeds its own book set (distinct ids from company A's).
|
||||||
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
|
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
|
||||||
@@ -1766,6 +1771,19 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
|
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
|
||||||
|
|
||||||
|
// The employee directory is per-company.
|
||||||
|
const empB = (await request('/api/employees', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: 'B Worker', employee_number: `EMP-${suffix}` }) })).employee;
|
||||||
|
if (!empB || empB.entity_id !== companyB) throw new Error('New employee was not scoped to the active company');
|
||||||
|
const empsA = (await request('/api/employees', { headers: companyHeaders(companyA) })).employees;
|
||||||
|
const empsB = (await request('/api/employees', { headers: companyHeaders(companyB) })).employees;
|
||||||
|
if (empsA.some((e) => e.id === empB.id)) throw new Error('Company A sees company B employee');
|
||||||
|
if (!empsB.some((e) => e.id === empB.id)) throw new Error('Company B employee missing');
|
||||||
|
// Company B's employee cannot be attached to a company A assignment (resolves to no employee).
|
||||||
|
const crossEmpAssign = (await request('/api/assignments', {
|
||||||
|
method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ asset_id: assetAonly.id, employee_id: empB.id })
|
||||||
|
})).assignment;
|
||||||
|
if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee');
|
||||||
|
|
||||||
console.log('Smoke test passed');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<v-text-field v-model="form.name" class="full" label="Company name *" autofocus />
|
<v-text-field v-model="form.name" class="full" label="Company name *" autofocus />
|
||||||
<v-text-field v-model="form.legal_name" class="full" label="Legal name" />
|
<v-text-field v-model="form.legal_name" class="full" label="Legal name" />
|
||||||
<v-text-field v-model="form.tax_id" label="Tax ID / EIN" />
|
<v-text-field v-model="form.federal_tax_id" label="Federal tax ID (EIN)" />
|
||||||
|
<v-text-field v-model="form.state_tax_id" label="State tax ID" />
|
||||||
|
<v-text-field v-model="form.local_tax_id" label="Local tax ID (optional)" />
|
||||||
<v-text-field v-model="form.base_currency" label="Base currency" placeholder="USD" />
|
<v-text-field v-model="form.base_currency" label="Base currency" placeholder="USD" />
|
||||||
<v-select v-model.number="form.fiscal_year_end_month" :items="monthItems" item-title="title" item-value="value" label="Fiscal year-end month" />
|
<v-select v-model.number="form.fiscal_year_end_month" :items="monthItems" item-title="title" item-value="value" label="Fiscal year-end month" />
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +92,7 @@ import DataTable from './DataTable.vue';
|
|||||||
import { apiRequest } from '../services/api';
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
function blankCompany() {
|
function blankCompany() {
|
||||||
return { id: null, name: '', legal_name: '', tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
|
return { id: null, name: '', legal_name: '', federal_tax_id: '', state_tax_id: '', local_tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -114,7 +116,7 @@ export default {
|
|||||||
columns: [
|
columns: [
|
||||||
{ key: 'name', label: 'Company' },
|
{ key: 'name', label: 'Company' },
|
||||||
{ key: 'legal_name', label: 'Legal name' },
|
{ key: 'legal_name', label: 'Legal name' },
|
||||||
{ key: 'tax_id', label: 'Tax ID' },
|
{ key: 'federal_tax_id', label: 'Federal tax ID' },
|
||||||
{ key: 'base_currency', label: 'Currency' },
|
{ key: 'base_currency', label: 'Currency' },
|
||||||
{ key: 'status', label: 'Status', sortable: false }
|
{ key: 'status', label: 'Status', sortable: false }
|
||||||
]
|
]
|
||||||
@@ -142,7 +144,7 @@ export default {
|
|||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
},
|
},
|
||||||
openEdit(row) {
|
openEdit(row) {
|
||||||
this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', tax_id: row.tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 };
|
this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', federal_tax_id: row.federal_tax_id || '', state_tax_id: row.state_tax_id || '', local_tax_id: row.local_tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 };
|
||||||
this.dialogError = '';
|
this.dialogError = '';
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user