feat(workshop): vehicles must be assigned to a customer
- Added customer_id column to fleet_vehicles with FK to customers. - Enforced customer_id on create_vehicle endpoints (workshop and fleet). - New vehicle modal now requires a selected customer and stores customer_id. - Migration v4.13 applied to all tenants.
This commit is contained in:
@@ -269,11 +269,13 @@ def list_all_history():
|
|||||||
def create_vehicle():
|
def create_vehicle():
|
||||||
"""Create a fleet vehicle.
|
"""Create a fleet vehicle.
|
||||||
|
|
||||||
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
Body: {customer_id, plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('plate') and not data.get('vin'):
|
if not data.get('plate') and not data.get('vin'):
|
||||||
return jsonify({'error': 'plate or vin is required'}), 400
|
return jsonify({'error': 'plate or vin is required'}), 400
|
||||||
|
if not data.get('customer_id'):
|
||||||
|
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
||||||
|
|
||||||
branch_id = data.get('branch_id', g.branch_id)
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
|
||||||
@@ -281,17 +283,23 @@ def create_vehicle():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
||||||
|
cust = cur.fetchone()
|
||||||
|
if not cust:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO fleet_vehicles
|
INSERT INTO fleet_vehicles
|
||||||
(branch_id, plate, vin, make, model, year,
|
(branch_id, customer_id, plate, vin, make, model, year,
|
||||||
current_mileage, fuel_type, color, owner_name, notes)
|
current_mileage, fuel_type, color, owner_name, notes)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
branch_id, data.get('plate'), data.get('vin'),
|
branch_id, data['customer_id'], data.get('plate'), data.get('vin'),
|
||||||
data.get('make'), data.get('model'), data.get('year'),
|
data.get('make'), data.get('model'), data.get('year'),
|
||||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||||
data.get('color'), data.get('owner_name'), data.get('notes'),
|
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
|
||||||
))
|
))
|
||||||
vehicle_id = cur.fetchone()[0]
|
vehicle_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
|||||||
@@ -413,25 +413,36 @@ def create_customer_for_workshop():
|
|||||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_auth('workshop.edit')
|
||||||
def create_vehicle_for_workshop():
|
def create_vehicle_for_workshop():
|
||||||
"""Create a fleet vehicle directly from the workshop flow."""
|
"""Create a fleet vehicle directly from the workshop flow.
|
||||||
|
|
||||||
|
Vehicles must be assigned to a customer.
|
||||||
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('plate') and not data.get('vin'):
|
if not data.get('plate') and not data.get('vin'):
|
||||||
return jsonify({'error': 'Se requiere placa o VIN'}), 400
|
return jsonify({'error': 'Se requiere placa o VIN'}), 400
|
||||||
|
if not data.get('customer_id'):
|
||||||
|
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
|
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
||||||
|
cust = cur.fetchone()
|
||||||
|
if not cust:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO fleet_vehicles
|
INSERT INTO fleet_vehicles
|
||||||
(branch_id, plate, vin, make, model, year,
|
(branch_id, customer_id, plate, vin, make, model, year,
|
||||||
current_mileage, fuel_type, color, owner_name, notes)
|
current_mileage, fuel_type, color, owner_name, notes)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
data.get('branch_id', g.branch_id), data.get('plate'), data.get('vin'),
|
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'), data.get('vin'),
|
||||||
data.get('make'), data.get('model'), data.get('year'),
|
data.get('make'), data.get('model'), data.get('year'),
|
||||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||||
data.get('color'), data.get('owner_name'), data.get('notes'),
|
data.get('color'), cust[0], data.get('notes'),
|
||||||
))
|
))
|
||||||
vehicle_id = cur.fetchone()[0]
|
vehicle_id = cur.fetchone()[0]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
20
pos/migrations/v4.13_fleet_vehicle_customer.sql
Normal file
20
pos/migrations/v4.13_fleet_vehicle_customer.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- v4.13_fleet_vehicle_customer.sql
|
||||||
|
-- Link fleet vehicles to a customer.
|
||||||
|
|
||||||
|
ALTER TABLE fleet_vehicles
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'fleet_vehicles_customer_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE fleet_vehicles
|
||||||
|
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
|
||||||
|
ON fleet_vehicles (customer_id);
|
||||||
@@ -708,6 +708,22 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function openNewVehicleModal(context) {
|
function openNewVehicleModal(context) {
|
||||||
newVehicleContext = context || 'edit';
|
newVehicleContext = context || 'edit';
|
||||||
|
var customerId = null;
|
||||||
|
var customerName = '';
|
||||||
|
if (newVehicleContext === 'new') {
|
||||||
|
var sel = document.getElementById('noCustomer');
|
||||||
|
customerId = sel ? parseInt(sel.value, 10) || null : null;
|
||||||
|
customerName = sel && sel.selectedIndex >= 0 ? sel.options[sel.selectedIndex].text : '';
|
||||||
|
} else {
|
||||||
|
customerId = currentOrder ? currentOrder.customer_id : null;
|
||||||
|
customerName = currentOrder ? (currentOrder.customer_name || '') : '';
|
||||||
|
}
|
||||||
|
if (!customerId) {
|
||||||
|
alert('Primero selecciona un cliente para poder crear el vehículo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('nvCustomerId').value = customerId;
|
||||||
|
document.getElementById('nvCustomerName').value = customerName;
|
||||||
document.getElementById('newVehicleModal').classList.add('is-open');
|
document.getElementById('newVehicleModal').classList.add('is-open');
|
||||||
document.getElementById('nvPlate').value = '';
|
document.getElementById('nvPlate').value = '';
|
||||||
document.getElementById('nvVIN').value = '';
|
document.getElementById('nvVIN').value = '';
|
||||||
@@ -715,7 +731,6 @@ var Workshop = (function() {
|
|||||||
document.getElementById('nvModel').value = '';
|
document.getElementById('nvModel').value = '';
|
||||||
document.getElementById('nvYear').value = '';
|
document.getElementById('nvYear').value = '';
|
||||||
document.getElementById('nvColor').value = '';
|
document.getElementById('nvColor').value = '';
|
||||||
document.getElementById('nvOwner').value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewVehicleModalFromNewOrder() {
|
function openNewVehicleModalFromNewOrder() {
|
||||||
@@ -729,18 +744,23 @@ var Workshop = (function() {
|
|||||||
async function saveNewVehicle() {
|
async function saveNewVehicle() {
|
||||||
var plate = document.getElementById('nvPlate').value.trim();
|
var plate = document.getElementById('nvPlate').value.trim();
|
||||||
var vin = document.getElementById('nvVIN').value.trim();
|
var vin = document.getElementById('nvVIN').value.trim();
|
||||||
|
var customerId = parseInt(document.getElementById('nvCustomerId').value, 10) || null;
|
||||||
if (!plate && !vin) {
|
if (!plate && !vin) {
|
||||||
alert('Se requiere al menos placa o VIN');
|
alert('Se requiere al menos placa o VIN');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!customerId) {
|
||||||
|
alert('El vehículo debe estar asignado a un cliente');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var payload = {
|
var payload = {
|
||||||
plate: plate,
|
plate: plate,
|
||||||
vin: vin,
|
vin: vin,
|
||||||
|
customer_id: customerId,
|
||||||
make: document.getElementById('nvMake').value.trim() || null,
|
make: document.getElementById('nvMake').value.trim() || null,
|
||||||
model: document.getElementById('nvModel').value.trim() || null,
|
model: document.getElementById('nvModel').value.trim() || null,
|
||||||
year: document.getElementById('nvYear').value ? parseInt(document.getElementById('nvYear').value, 10) : null,
|
year: document.getElementById('nvYear').value ? parseInt(document.getElementById('nvYear').value, 10) : null,
|
||||||
color: document.getElementById('nvColor').value.trim() || null,
|
color: document.getElementById('nvColor').value.trim() || null,
|
||||||
owner_name: document.getElementById('nvOwner').value.trim() || null,
|
|
||||||
branch_id: (currentOrder ? currentOrder.branch_id : null) || (window.POS_USER ? window.POS_USER.branch_id : null)
|
branch_id: (currentOrder ? currentOrder.branch_id : null) || (window.POS_USER ? window.POS_USER.branch_id : null)
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=40">
|
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=41">
|
||||||
<style>
|
<style>
|
||||||
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
||||||
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
||||||
@@ -372,8 +372,9 @@
|
|||||||
<input class="form-input" id="nvColor" placeholder="Ej. Blanco" />
|
<input class="form-input" id="nvColor" placeholder="Ej. Blanco" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field form-field--span2">
|
||||||
<label class="form-label" for="nvOwner">Dueño</label>
|
<label class="form-label" for="nvCustomerName">Cliente *</label>
|
||||||
<input class="form-input" id="nvOwner" placeholder="Nombre del dueño" />
|
<input type="hidden" id="nvCustomerId" />
|
||||||
|
<input class="form-input" id="nvCustomerName" readonly placeholder="Selecciona un cliente en la orden" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,7 +487,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/workshop.js?v=40" defer></script>
|
<script src="/pos/static/js/workshop.js?v=41" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user