feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,3 +91,7 @@ backups/
|
|||||||
# Local tools (AWS CLI)
|
# Local tools (AWS CLI)
|
||||||
tools/
|
tools/
|
||||||
|
|
||||||
|
# Rached migration session artifacts (tokens / captures / samples)
|
||||||
|
rached_*.json
|
||||||
|
rached_*.txt
|
||||||
|
|
||||||
|
|||||||
38
pos/app.py
38
pos/app.py
@@ -10,24 +10,20 @@ def create_app():
|
|||||||
from middleware_tenant import resolve_tenant
|
from middleware_tenant import resolve_tenant
|
||||||
app.before_request(resolve_tenant)
|
app.before_request(resolve_tenant)
|
||||||
|
|
||||||
# ─── Server-side guard: workshop/mechanic users only see /pos/workshop ──────
|
# NOTE: Page-level routing guards are handled client-side by app-init.js
|
||||||
@app.before_request
|
# using the employee's current permissions; API endpoints enforce their own
|
||||||
def restrict_workshop_users():
|
# permission checks via @require_auth.
|
||||||
path = request.path
|
|
||||||
if not path.startswith('/pos/'):
|
|
||||||
return
|
|
||||||
if path == '/pos/workshop' or path.startswith('/pos/static/') or path.startswith('/pos/api/') or path == '/pos/sw.js' or path == '/pos/login' or path.startswith('/pos/login'):
|
|
||||||
return
|
|
||||||
role = request.cookies.get('pos_role', '').lower()
|
|
||||||
if role in ('workshop', 'mechanic'):
|
|
||||||
return redirect('/pos/workshop')
|
|
||||||
|
|
||||||
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
||||||
@app.route('/pos/sw.js')
|
@app.route('/pos/sw.js')
|
||||||
def pos_sw():
|
def pos_sw():
|
||||||
from flask import send_from_directory
|
from flask import send_from_directory, make_response
|
||||||
return send_from_directory('static/pwa', 'sw.js',
|
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
||||||
mimetype='application/javascript')
|
mimetype='application/javascript'))
|
||||||
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from blueprints.auth_bp import auth_bp
|
from blueprints.auth_bp import auth_bp
|
||||||
@@ -192,7 +188,11 @@ def create_app():
|
|||||||
|
|
||||||
@app.route('/pos/dashboard')
|
@app.route('/pos/dashboard')
|
||||||
def pos_dashboard():
|
def pos_dashboard():
|
||||||
return render_template('dashboard.html')
|
response = make_response(render_template('dashboard.html'))
|
||||||
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
@app.route('/pos/config')
|
@app.route('/pos/config')
|
||||||
def pos_config():
|
def pos_config():
|
||||||
@@ -234,6 +234,14 @@ def create_app():
|
|||||||
def pos_historical_sales():
|
def pos_historical_sales():
|
||||||
return render_template('historical_sales.html')
|
return render_template('historical_sales.html')
|
||||||
|
|
||||||
|
@app.route('/pos/remission-notes')
|
||||||
|
def pos_remission_notes():
|
||||||
|
response = make_response(render_template('remission_notes.html'))
|
||||||
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
from flask import Blueprint, request, jsonify, g, make_response
|
from flask import Blueprint, request, jsonify, g, make_response
|
||||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||||
from tenant_db import get_tenant_conn, get_master_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||||
|
|
||||||
@@ -158,6 +159,68 @@ def login_pin():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/refresh', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def refresh_token():
|
||||||
|
"""Reissue the JWT with the employee's current permissions from the DB.
|
||||||
|
|
||||||
|
This lets permission changes take effect without forcing a full re-login.
|
||||||
|
The original expiration time is preserved.
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
tenant_id = payload.get('tenant_id')
|
||||||
|
employee_id = payload.get('employee_id')
|
||||||
|
conn = get_tenant_conn(tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
||||||
|
FROM employees e
|
||||||
|
WHERE e.id = %s AND e.is_active = true
|
||||||
|
""",
|
||||||
|
(employee_id,)
|
||||||
|
)
|
||||||
|
emp = cur.fetchone()
|
||||||
|
if not emp:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Employee not found or inactive'}), 404
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
||||||
|
(emp[0],)
|
||||||
|
)
|
||||||
|
permissions = [r[0] for r in cur.fetchall()]
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
new_payload = {
|
||||||
|
'tenant_id': tenant_id,
|
||||||
|
'employee_id': emp[0],
|
||||||
|
'name': emp[1],
|
||||||
|
'role': emp[2],
|
||||||
|
'branch_id': emp[3],
|
||||||
|
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
||||||
|
'permissions': permissions,
|
||||||
|
'device_id': payload.get('device_id', 'unknown'),
|
||||||
|
'type': 'pos_access',
|
||||||
|
'exp': payload.get('exp'),
|
||||||
|
'iat': datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
||||||
|
return jsonify({
|
||||||
|
'token': token,
|
||||||
|
'employee': {
|
||||||
|
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
||||||
|
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
||||||
|
},
|
||||||
|
'permissions': permissions
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
|
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
|
||||||
@auth_bp.route('/employees', methods=['GET'])
|
@auth_bp.route('/employees', methods=['GET'])
|
||||||
def list_login_employees(tenant_id=None):
|
def list_login_employees(tenant_id=None):
|
||||||
@@ -191,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
|||||||
name = row[1]
|
name = row[1]
|
||||||
parts = name.split()
|
parts = name.split()
|
||||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||||
employees.append({
|
employees.append({
|
||||||
'id': row[0],
|
'id': row[0],
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|||||||
@@ -3,16 +3,26 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||||
|
|
||||||
|
|
||||||
|
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
||||||
|
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
||||||
|
|
||||||
|
|
||||||
|
def _can_operate_register():
|
||||||
|
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/open', methods=['POST'])
|
@cashregister_bp.route('/open', methods=['POST'])
|
||||||
@require_auth('pos.sell')
|
@require_auth()
|
||||||
def open_register():
|
def open_register():
|
||||||
|
if not _can_operate_register():
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||||
"""Open a cash register session.
|
"""Open a cash register session.
|
||||||
|
|
||||||
Body: {register_number: int, opening_amount: float}
|
Body: {register_number: int, opening_amount: float}
|
||||||
@@ -84,7 +94,7 @@ def open_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/current', methods=['GET'])
|
@cashregister_bp.route('/current', methods=['GET'])
|
||||||
@require_auth('pos.sell')
|
@require_auth()
|
||||||
def current_register():
|
def current_register():
|
||||||
"""Get the current open register for this employee."""
|
"""Get the current open register for this employee."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -115,8 +125,10 @@ def current_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/movement', methods=['POST'])
|
@cashregister_bp.route('/movement', methods=['POST'])
|
||||||
@require_auth('pos.sell')
|
@require_auth()
|
||||||
def cash_movement():
|
def cash_movement():
|
||||||
|
if not _can_operate_register():
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||||
"""Record a cash in/out movement with mandatory reason.
|
"""Record a cash in/out movement with mandatory reason.
|
||||||
|
|
||||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||||
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||||
@require_auth('pos.sell')
|
@require_auth()
|
||||||
def cut_x():
|
def cut_x():
|
||||||
|
if not _can_operate_register():
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -301,8 +315,10 @@ def cut_x():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||||
@require_auth('pos.sell')
|
@require_auth()
|
||||||
def cut_z():
|
def cut_z():
|
||||||
|
if not _can_operate_register():
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||||
"""Final cut (corte Z): close the register.
|
"""Final cut (corte Z): close the register.
|
||||||
|
|
||||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||||
@@ -379,12 +395,18 @@ def cut_z():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/history', methods=['GET'])
|
@cashregister_bp.route('/history', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth()
|
||||||
def register_history():
|
def register_history():
|
||||||
"""List closed registers with summary.
|
"""List closed registers with summary.
|
||||||
|
|
||||||
Query params: date_from, date_to, employee_id, page, per_page
|
Query params: date_from, date_to, employee_id, page, per_page
|
||||||
|
|
||||||
|
Permission rules:
|
||||||
|
- owner/admin and users with pos.view can query any employee.
|
||||||
|
- Cashiers/counters without pos.view can only query their own registers.
|
||||||
"""
|
"""
|
||||||
|
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -402,6 +424,9 @@ def register_history():
|
|||||||
date_to = request.args.get('date_to')
|
date_to = request.args.get('date_to')
|
||||||
employee_id = request.args.get('employee_id')
|
employee_id = request.args.get('employee_id')
|
||||||
|
|
||||||
|
if not can_view_all:
|
||||||
|
employee_id = g.employee_id
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
where_clauses.append("cr.closed_at >= %s")
|
where_clauses.append("cr.closed_at >= %s")
|
||||||
params.append(date_from)
|
params.append(date_from)
|
||||||
@@ -576,3 +601,89 @@ def daily_summary():
|
|||||||
'movements_out': movements['out'],
|
'movements_out': movements['out'],
|
||||||
'registers': registers,
|
'registers': registers,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def register_sales(register_id):
|
||||||
|
"""List the sales associated with a specific cash register (cash cut).
|
||||||
|
|
||||||
|
Returns sales details and a summary by payment method. Accessible to
|
||||||
|
owners/admins, users with pos.view, or the employee who operated the register.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
||||||
|
(register_id,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Register not found'}), 404
|
||||||
|
|
||||||
|
register_employee_id, register_branch_id = row
|
||||||
|
|
||||||
|
can_view = (
|
||||||
|
g.employee_role in ('owner', 'admin') or
|
||||||
|
has_permission('pos.view') or
|
||||||
|
register_employee_id == g.employee_id
|
||||||
|
)
|
||||||
|
if not can_view:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||||
|
|
||||||
|
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Register belongs to another branch'}), 403
|
||||||
|
|
||||||
|
where = "s.register_id = %s"
|
||||||
|
params = [register_id]
|
||||||
|
if g.branch_id:
|
||||||
|
where += " AND s.branch_id = %s"
|
||||||
|
params.append(g.branch_id)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||||
|
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
||||||
|
s.created_at, c.name as customer_name, e.name as employee_name
|
||||||
|
FROM sales s
|
||||||
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
sales = []
|
||||||
|
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sale = {
|
||||||
|
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
||||||
|
'subtotal': float(r[3]) if r[3] else 0,
|
||||||
|
'discount_total': float(r[4]) if r[4] else 0,
|
||||||
|
'tax_total': float(r[5]) if r[5] else 0,
|
||||||
|
'total': float(r[6]) if r[6] else 0,
|
||||||
|
'amount_paid': float(r[7]) if r[7] else 0,
|
||||||
|
'change_given': float(r[8]) if r[8] else 0,
|
||||||
|
'status': r[9], 'created_at': str(r[10]),
|
||||||
|
'customer_name': r[11], 'employee_name': r[12]
|
||||||
|
}
|
||||||
|
sales.append(sale)
|
||||||
|
if sale['status'] == 'completed':
|
||||||
|
summary['total'] += sale['total']
|
||||||
|
summary['count'] += 1
|
||||||
|
m = sale['payment_method'] or 'Otro'
|
||||||
|
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'register_id': register_id,
|
||||||
|
'sales': sales,
|
||||||
|
'summary': {
|
||||||
|
'total': round(summary['total'], 2),
|
||||||
|
'count': summary['count'],
|
||||||
|
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||||
"""Config blueprint: tenant configuration, branches, theming."""
|
"""Config blueprint: tenant configuration, branches, theming."""
|
||||||
|
|
||||||
|
import json
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
@@ -8,6 +9,109 @@ from tenant_db import get_tenant_conn
|
|||||||
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||||
|
|
||||||
|
|
||||||
|
# Default permission set per role. Can be overridden per tenant via role_permissions config.
|
||||||
|
_DEFAULT_ROLE_PERMISSIONS = {
|
||||||
|
'owner': [], # owner bypasses permission checks
|
||||||
|
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||||
|
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||||
|
'catalog.view', 'catalog.edit',
|
||||||
|
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||||
|
'invoicing.view', 'invoicing.create',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'config.view', 'config.edit', 'config.edit_prices',
|
||||||
|
'workshop.view', 'workshop.edit',
|
||||||
|
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
||||||
|
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
|
||||||
|
'catalog.view',
|
||||||
|
'inventory.view', 'inventory.create',
|
||||||
|
'customers.view', 'customers.create',
|
||||||
|
'workshop.view', 'workshop.edit', 'workshop.add_items',
|
||||||
|
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
|
||||||
|
'counter': ['pos.remission', 'pos.view',
|
||||||
|
'catalog.view',
|
||||||
|
'inventory.view', 'inventory.create',
|
||||||
|
'customers.view', 'customers.create'],
|
||||||
|
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||||
|
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||||
|
'accountant': ['accounting.view', 'accounting.create',
|
||||||
|
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'customers.view',
|
||||||
|
'fleet.view'],
|
||||||
|
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||||
|
'mechanic': ['workshop.view'],
|
||||||
|
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
|
||||||
|
'customers.view', 'customers.create'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_AVAILABLE_PERMISSIONS = [
|
||||||
|
{'module': 'Punto de Venta', 'permissions': [
|
||||||
|
{'key': 'pos.sell', 'label': 'Realizar ventas'},
|
||||||
|
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
|
||||||
|
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
|
||||||
|
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
|
||||||
|
{'key': 'pos.remission', 'label': 'Notas de remisión'},
|
||||||
|
]},
|
||||||
|
{'module': 'Inventario', 'permissions': [
|
||||||
|
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
||||||
|
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
||||||
|
{'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'},
|
||||||
|
]},
|
||||||
|
{'module': 'Catálogo', 'permissions': [
|
||||||
|
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||||
|
]},
|
||||||
|
{'module': 'Clientes', 'permissions': [
|
||||||
|
{'key': 'customers.view', 'label': 'Ver clientes'},
|
||||||
|
{'key': 'customers.create', 'label': 'Crear/editar clientes'},
|
||||||
|
]},
|
||||||
|
{'module': 'Taller', 'permissions': [
|
||||||
|
{'key': 'workshop.view', 'label': 'Ver órdenes'},
|
||||||
|
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes'},
|
||||||
|
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra'},
|
||||||
|
]},
|
||||||
|
{'module': 'Facturación', 'permissions': [
|
||||||
|
{'key': 'invoicing.view', 'label': 'Ver facturas'},
|
||||||
|
{'key': 'invoicing.create', 'label': 'Crear facturas'},
|
||||||
|
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
|
||||||
|
]},
|
||||||
|
{'module': 'Configuración', 'permissions': [
|
||||||
|
{'key': 'config.edit', 'label': 'Editar configuración'},
|
||||||
|
{'key': 'config.edit_prices', 'label': 'Modificar precios'},
|
||||||
|
]},
|
||||||
|
{'module': 'Contabilidad', 'permissions': [
|
||||||
|
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
|
||||||
|
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
|
||||||
|
]},
|
||||||
|
{'module': 'Reportes', 'permissions': [
|
||||||
|
{'key': 'reports.view', 'label': 'Ver reportes'},
|
||||||
|
{'key': 'reports.financial', 'label': 'Reportes financieros'},
|
||||||
|
]},
|
||||||
|
{'module': 'Flotillas', 'permissions': [
|
||||||
|
{'key': 'fleet.view', 'label': 'Ver flotillas'},
|
||||||
|
{'key': 'fleet.create', 'label': 'Crear flotillas'},
|
||||||
|
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
|
||||||
|
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_role_permissions(conn, role):
|
||||||
|
"""Return configured permissions for a role, falling back to defaults."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
configured = json.loads(row[0])
|
||||||
|
if isinstance(configured, dict) and role in configured:
|
||||||
|
return list(configured.get(role, []))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/branches', methods=['GET'])
|
@config_bp.route('/branches', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def list_branches():
|
def list_branches():
|
||||||
@@ -250,7 +354,7 @@ def create_employee():
|
|||||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||||
if data['role'] not in valid_roles:
|
if data['role'] not in valid_roles:
|
||||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||||
|
|
||||||
@@ -265,33 +369,8 @@ def create_employee():
|
|||||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||||
emp_id = cur.fetchone()[0]
|
emp_id = cur.fetchone()[0]
|
||||||
|
|
||||||
# Set default permissions by role
|
# Set default permissions by role (configurable per tenant)
|
||||||
role_permissions = {
|
for perm in _get_role_permissions(conn, data['role']):
|
||||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
|
||||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
|
||||||
'catalog.view', 'catalog.edit',
|
|
||||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
|
||||||
'invoicing.view', 'invoicing.create',
|
|
||||||
'reports.view', 'reports.financial',
|
|
||||||
'config.view', 'config.edit', 'config.edit_prices',
|
|
||||||
'workshop.view', 'workshop.edit',
|
|
||||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
|
||||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
|
||||||
'catalog.view',
|
|
||||||
'inventory.view', 'inventory.create',
|
|
||||||
'customers.view', 'customers.create'],
|
|
||||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
|
||||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
|
||||||
'accountant': ['accounting.view', 'accounting.create',
|
|
||||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
|
||||||
'reports.view', 'reports.financial',
|
|
||||||
'customers.view',
|
|
||||||
'fleet.view'],
|
|
||||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
|
||||||
'mechanic': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
|
||||||
}
|
|
||||||
|
|
||||||
for perm in role_permissions.get(data['role'], []):
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
(emp_id, perm)
|
(emp_id, perm)
|
||||||
@@ -307,6 +386,61 @@ def create_employee():
|
|||||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/role-permissions', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_role_permissions_config():
|
||||||
|
"""Return the configured permissions for each role and the available permission list."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
configured = {}
|
||||||
|
for role in _DEFAULT_ROLE_PERMISSIONS:
|
||||||
|
configured[role] = _get_role_permissions(conn, role)
|
||||||
|
return jsonify({
|
||||||
|
'roles': configured,
|
||||||
|
'available': _AVAILABLE_PERMISSIONS,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/role-permissions', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def save_role_permissions_config():
|
||||||
|
"""Save the permission mapping per role. Only owner/admin can edit."""
|
||||||
|
if g.employee_role not in ('owner', 'admin'):
|
||||||
|
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'roles' not in data:
|
||||||
|
return jsonify({'error': 'roles object required'}), 400
|
||||||
|
for role, perms in data['roles'].items():
|
||||||
|
if role not in _DEFAULT_ROLE_PERMISSIONS:
|
||||||
|
return jsonify({'error': f'Invalid role: {role}'}), 400
|
||||||
|
if not isinstance(perms, list):
|
||||||
|
return jsonify({'error': f'permissions for {role} must be a list'}), 400
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (json.dumps(data['roles']),))
|
||||||
|
|
||||||
|
# Apply the new permissions to existing employees of each affected role
|
||||||
|
for role, perms in data['roles'].items():
|
||||||
|
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
|
||||||
|
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
|
||||||
|
emp_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
for emp_id in emp_ids:
|
||||||
|
for perm in perms:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(emp_id, perm)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def update_employee(emp_id):
|
def update_employee(emp_id):
|
||||||
@@ -767,7 +901,7 @@ def update_whatsapp_config():
|
|||||||
|
|
||||||
|
|
||||||
@config_bp.route('/modules', methods=['GET'])
|
@config_bp.route('/modules', methods=['GET'])
|
||||||
@require_auth('config.view')
|
@require_auth()
|
||||||
def get_modules():
|
def get_modules():
|
||||||
"""Get enabled modules for this tenant."""
|
"""Get enabled modules for this tenant."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -820,6 +954,35 @@ def update_modules():
|
|||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/counter-remission', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_counter_remission_config():
|
||||||
|
"""Get counter remission note feature flag for this tenant."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/counter-remission', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_counter_remission_config():
|
||||||
|
"""Enable/disable counter remission notes for this tenant."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
enabled = 'true' if data.get('enabled') else 'false'
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", ('counter_remission_enabled', enabled))
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'enabled': enabled == 'true'})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_onboarding_status():
|
def get_onboarding_status():
|
||||||
|
|||||||
@@ -10,10 +10,20 @@ from services.audit import log_action
|
|||||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view_customers():
|
||||||
|
"""Taller/counter employees need customer autocomplete even without customers.view."""
|
||||||
|
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||||
|
|
||||||
|
|
||||||
|
def _can_create_customer():
|
||||||
|
"""Taller/counter employees can create customers on the fly from an order."""
|
||||||
|
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||||
|
|
||||||
|
|
||||||
# ─── Customer CRUD ─────────────────────────────
|
# ─── Customer CRUD ─────────────────────────────
|
||||||
|
|
||||||
@customers_bp.route('', methods=['GET'])
|
@customers_bp.route('', methods=['GET'])
|
||||||
@require_auth('customers.view')
|
@require_auth()
|
||||||
def list_customers():
|
def list_customers():
|
||||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||||
|
|
||||||
@@ -23,6 +33,8 @@ def list_customers():
|
|||||||
per_page: items per page (default 50, max 200)
|
per_page: items per page (default 50, max 200)
|
||||||
branch_id: filter by branch (default: current user's branch)
|
branch_id: filter by branch (default: current user's branch)
|
||||||
"""
|
"""
|
||||||
|
if not _can_view_customers():
|
||||||
|
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -219,13 +231,15 @@ def get_customer_purchases(customer_id):
|
|||||||
|
|
||||||
|
|
||||||
@customers_bp.route('', methods=['POST'])
|
@customers_bp.route('', methods=['POST'])
|
||||||
@require_auth('customers.create')
|
@require_auth()
|
||||||
def create_customer():
|
def create_customer():
|
||||||
"""Create a new customer.
|
"""Create a new customer.
|
||||||
|
|
||||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||||
phone, address, price_tier, credit_limit, vehicle_info}
|
phone, address, price_tier, credit_limit, vehicle_info}
|
||||||
"""
|
"""
|
||||||
|
if not _can_create_customer():
|
||||||
|
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return jsonify({'error': 'name is required'}), 400
|
return jsonify({'error': 'name is required'}), 400
|
||||||
|
|||||||
@@ -103,8 +103,13 @@ def list_items():
|
|||||||
# branch_id no longer filters inventory rows (shared catalog).
|
# branch_id no longer filters inventory rows (shared catalog).
|
||||||
# It is used only to show per-branch stock.
|
# It is used only to show per-branch stock.
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
# Search also matches alternate / alias SKUs.
|
||||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
where_clauses.append(
|
||||||
|
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
||||||
|
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
||||||
|
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
||||||
|
)
|
||||||
|
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||||
if category:
|
if category:
|
||||||
where_clauses.append("i.category_id = %s")
|
where_clauses.append("i.category_id = %s")
|
||||||
params.append(int(category))
|
params.append(int(category))
|
||||||
@@ -490,6 +495,7 @@ def bulk_import_items():
|
|||||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||||
'ubicacion': 'location', 'categoria': 'category',
|
'ubicacion': 'location', 'categoria': 'category',
|
||||||
|
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
||||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||||
@@ -599,7 +605,20 @@ def bulk_import_items():
|
|||||||
created_ids.append(item_id)
|
created_ids.append(item_id)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# ---------- 2. Vehicle compatibility ----------
|
# ---------- 2. Secondary SKU alias ----------
|
||||||
|
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
||||||
|
if sku_secondary:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(item_id, sku_secondary, 'Secundario')
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ---------- 3. Vehicle compatibility ----------
|
||||||
make = str(row.get('make', '')).strip()
|
make = str(row.get('make', '')).strip()
|
||||||
model = str(row.get('model', '')).strip()
|
model = str(row.get('model', '')).strip()
|
||||||
year_str = str(row.get('year', '')).strip()
|
year_str = str(row.get('year', '')).strip()
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
|
|||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.pos_engine import (
|
from services.pos_engine import (
|
||||||
process_sale, cancel_sale, calculate_totals,
|
process_sale, cancel_sale, calculate_totals,
|
||||||
get_price_for_customer, get_margin_info
|
get_price_for_customer, get_margin_info,
|
||||||
|
create_remission_note, pay_pending_sale
|
||||||
)
|
)
|
||||||
from services.inventory_engine import get_stock
|
from services.inventory_engine import get_stock
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
@@ -22,6 +23,15 @@ from config import JWT_SECRET
|
|||||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_allows_negative_stock(conn):
|
||||||
|
"""Return True if the tenant explicitly allows selling below zero stock."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
|
||||||
def _enrich_items(cur, items, customer_id=None):
|
def _enrich_items(cur, items, customer_id=None):
|
||||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||||
|
|
||||||
@@ -104,17 +114,19 @@ def create_sale():
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
# Verify stock availability per item for the active branch
|
# Verify stock availability per item for the active branch
|
||||||
|
# (skipped for tenants configured to allow negative stock / oversell)
|
||||||
branch_id = data.get('branch_id', g.branch_id)
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
for item in data.get('items', []):
|
if not _tenant_allows_negative_stock(conn):
|
||||||
inv_id = item.get('inventory_id')
|
for item in data.get('items', []):
|
||||||
qty = int(item.get('quantity', 1))
|
inv_id = item.get('inventory_id')
|
||||||
if inv_id:
|
qty = int(item.get('quantity', 1))
|
||||||
available = get_stock(conn, inv_id, branch_id)
|
if inv_id:
|
||||||
if available < qty:
|
available = get_stock(conn, inv_id, branch_id)
|
||||||
conn.close()
|
if available < qty:
|
||||||
return jsonify({
|
conn.close()
|
||||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
return jsonify({
|
||||||
}), 400
|
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
@@ -131,8 +143,89 @@ def create_sale():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/sales/remission', methods=['POST'])
|
||||||
|
@require_auth('pos.remission')
|
||||||
|
def create_remission():
|
||||||
|
"""Create a counter remission note (pending payment, reserved stock)."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
if not _tenant_allows_negative_stock(conn):
|
||||||
|
for item in data.get('items', []):
|
||||||
|
inv_id = item.get('inventory_id')
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
if inv_id:
|
||||||
|
available = get_stock(conn, inv_id, branch_id)
|
||||||
|
if available < qty:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
sale = create_remission_note(conn, {
|
||||||
|
'tenant_id': g.tenant_id,
|
||||||
|
'branch_id': branch_id,
|
||||||
|
'customer_id': data.get('customer_id'),
|
||||||
|
'items': data.get('items', []),
|
||||||
|
'notes': data.get('notes'),
|
||||||
|
'register_id': data.get('register_id'),
|
||||||
|
'currency': data.get('currency', 'MXN'),
|
||||||
|
'exchange_rate': data.get('exchange_rate'),
|
||||||
|
'courier_id': data.get('courier_id'),
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(sale), 201
|
||||||
|
except ValueError as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def pay_sale(sale_id):
|
||||||
|
"""Pay a pending counter remission note.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
|
||||||
|
amount_paid: float,
|
||||||
|
payment_details: [{method, amount, reference}],
|
||||||
|
register_id: int,
|
||||||
|
reference: str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
sale = pay_pending_sale(conn, sale_id, {
|
||||||
|
'payment_method': data.get('payment_method', 'efectivo'),
|
||||||
|
'amount_paid': data.get('amount_paid', 0),
|
||||||
|
'payment_details': data.get('payment_details', []),
|
||||||
|
'register_id': data.get('register_id'),
|
||||||
|
'reference': data.get('reference', ''),
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(sale), 200
|
||||||
|
except ValueError as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales', methods=['GET'])
|
@pos_bp.route('/sales', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth()
|
||||||
def list_sales():
|
def list_sales():
|
||||||
"""List sales with filters.
|
"""List sales with filters.
|
||||||
|
|
||||||
@@ -146,6 +239,13 @@ def list_sales():
|
|||||||
page: int (default 1)
|
page: int (default 1)
|
||||||
per_page: int (default 50, max 200)
|
per_page: int (default 50, max 200)
|
||||||
"""
|
"""
|
||||||
|
sale_type = request.args.get('sale_type')
|
||||||
|
is_remission = sale_type == 'counter_remission'
|
||||||
|
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
|
||||||
|
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
|
||||||
|
if not (can_view_all or can_view_remissions):
|
||||||
|
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -160,6 +260,10 @@ def list_sales():
|
|||||||
employee_id = request.args.get('employee_id')
|
employee_id = request.args.get('employee_id')
|
||||||
customer_id = request.args.get('customer_id')
|
customer_id = request.args.get('customer_id')
|
||||||
status = request.args.get('status')
|
status = request.args.get('status')
|
||||||
|
sale_type = request.args.get('sale_type')
|
||||||
|
q = request.args.get('q')
|
||||||
|
customer_q = request.args.get('customer')
|
||||||
|
courier_id = request.args.get('courier_id')
|
||||||
register_id = request.args.get('register_id')
|
register_id = request.args.get('register_id')
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
@@ -177,6 +281,18 @@ def list_sales():
|
|||||||
if status:
|
if status:
|
||||||
where_clauses.append("s.status = %s")
|
where_clauses.append("s.status = %s")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
if sale_type:
|
||||||
|
where_clauses.append("s.sale_type = %s")
|
||||||
|
params.append(sale_type)
|
||||||
|
if q:
|
||||||
|
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
|
||||||
|
params.extend([f'%{q}%', f'%{q}%'])
|
||||||
|
if customer_q:
|
||||||
|
where_clauses.append("c.name ILIKE %s")
|
||||||
|
params.append(f'%{customer_q}%')
|
||||||
|
if courier_id:
|
||||||
|
where_clauses.append("s.courier_id = %s")
|
||||||
|
params.append(int(courier_id))
|
||||||
if register_id:
|
if register_id:
|
||||||
where_clauses.append("s.register_id = %s")
|
where_clauses.append("s.register_id = %s")
|
||||||
params.append(int(register_id))
|
params.append(int(register_id))
|
||||||
@@ -195,12 +311,14 @@ def list_sales():
|
|||||||
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
||||||
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||||
s.tax_total, s.total, s.amount_paid, s.change_given,
|
s.tax_total, s.total, s.amount_paid, s.change_given,
|
||||||
s.status, s.created_at,
|
s.status, s.created_at, s.courier_id,
|
||||||
e.name as employee_name,
|
e.name as employee_name,
|
||||||
c.name as customer_name
|
c.name as customer_name,
|
||||||
|
co.name as courier_name
|
||||||
FROM sales s
|
FROM sales s
|
||||||
LEFT JOIN employees e ON s.employee_id = e.id
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
@@ -219,7 +337,9 @@ def list_sales():
|
|||||||
'amount_paid': float(r[11]) if r[11] else 0,
|
'amount_paid': float(r[11]) if r[11] else 0,
|
||||||
'change_given': float(r[12]) if r[12] else 0,
|
'change_given': float(r[12]) if r[12] else 0,
|
||||||
'status': r[13], 'created_at': str(r[14]),
|
'status': r[13], 'created_at': str(r[14]),
|
||||||
'employee_name': r[15], 'customer_name': r[16],
|
'courier_id': r[15],
|
||||||
|
'employee_name': r[16], 'customer_name': r[17],
|
||||||
|
'courier_name': r[18],
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -1475,6 +1595,7 @@ def convert_quotation(quot_id):
|
|||||||
'register_id': data.get('register_id'),
|
'register_id': data.get('register_id'),
|
||||||
'amount_paid': data.get('amount_paid', 0),
|
'amount_paid': data.get('amount_paid', 0),
|
||||||
'payment_details': data.get('payment_details', []),
|
'payment_details': data.get('payment_details', []),
|
||||||
|
'reference': data.get('reference', ''),
|
||||||
'notes': f'Convertida de cotizacion #{quot_id}',
|
'notes': f'Convertida de cotizacion #{quot_id}',
|
||||||
'currency': quot_currency,
|
'currency': quot_currency,
|
||||||
'exchange_rate': quot_rate,
|
'exchange_rate': quot_rate,
|
||||||
@@ -2545,6 +2666,79 @@ def print_ticket(sale_id):
|
|||||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def print_remission(sale_id):
|
||||||
|
"""Generate printable data for a counter remission note."""
|
||||||
|
from middleware import has_permission
|
||||||
|
if not (has_permission('pos.remission') or has_permission('pos.sell')):
|
||||||
|
return jsonify({'error': 'Missing permissions'}), 403
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
|
||||||
|
FROM sales s
|
||||||
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||||
|
WHERE s.id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
|
cols = [desc[0] for desc in cur.description]
|
||||||
|
sale = dict(zip(cols, row))
|
||||||
|
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||||
|
if sale.get(k) is not None:
|
||||||
|
sale[k] = float(sale[k])
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT name, quantity, unit_price, subtotal
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
items = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
'name': r[0], 'quantity': r[1],
|
||||||
|
'unit_price': float(r[2]) if r[2] else 0,
|
||||||
|
'subtotal': float(r[3]) if r[3] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||||
|
for rw in cur.fetchall():
|
||||||
|
if rw[0] == 'business_name':
|
||||||
|
business_info['name'] = rw[1]
|
||||||
|
else:
|
||||||
|
business_info[rw[0]] = rw[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'folio': f'NR-{sale["id"]}',
|
||||||
|
'date': str(sale.get('created_at', '')),
|
||||||
|
'employee': sale.get('employee_name', ''),
|
||||||
|
'customer': sale.get('customer_name', ''),
|
||||||
|
'courier': sale.get('courier_name', ''),
|
||||||
|
'items': items,
|
||||||
|
'subtotal': sale.get('subtotal', 0),
|
||||||
|
'discount_total': sale.get('discount_total', 0),
|
||||||
|
'tax_total': sale.get('tax_total', 0),
|
||||||
|
'total': sale.get('total', 0),
|
||||||
|
'status': sale.get('status', ''),
|
||||||
|
'business_name': business_info.get('name', ''),
|
||||||
|
'business_rfc': business_info.get('rfc', ''),
|
||||||
|
'business_address': business_info.get('address', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||||
|
|
||||||
PUBLIC_QUOTE_TEMPLATE = """
|
PUBLIC_QUOTE_TEMPLATE = """
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Prefix: /pos/api/service-orders
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, g, jsonify, request
|
from flask import Blueprint, g, jsonify, request
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
@@ -11,6 +12,7 @@ from services.service_order_engine import (
|
|||||||
add_item,
|
add_item,
|
||||||
add_labor,
|
add_labor,
|
||||||
assign_mechanic,
|
assign_mechanic,
|
||||||
|
convert_to_remission,
|
||||||
convert_to_sale,
|
convert_to_sale,
|
||||||
create_service_catalog_item,
|
create_service_catalog_item,
|
||||||
create_service_order,
|
create_service_order,
|
||||||
@@ -35,8 +37,91 @@ from tenant_db import get_tenant_conn
|
|||||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||||
|
|
||||||
|
|
||||||
|
# Roles allowed to view or edit service orders.
|
||||||
|
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
|
||||||
|
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
||||||
|
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
|
||||||
|
|
||||||
|
# Statuses that mechanics are not allowed to see (single shared mechanic account).
|
||||||
|
_MECHANIC_HIDDEN_STATUSES = {
|
||||||
|
'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial',
|
||||||
|
'por_facturar', 'facturada'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view_workshop():
|
||||||
|
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _can_edit_workshop():
|
||||||
|
return g.employee_role in _WORKSHOP_EDIT_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def _is_restricted_workshop_viewer():
|
||||||
|
return g.employee_role in ('workshop', 'mechanic')
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_order_for_mechanic(order):
|
||||||
|
"""Remove customer/commercial data from the order payload for mechanics."""
|
||||||
|
if not _is_restricted_workshop_viewer():
|
||||||
|
return order
|
||||||
|
sensitive = {
|
||||||
|
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||||
|
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
||||||
|
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
||||||
|
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
||||||
|
'final_cost', 'total_parts', 'total_labor', 'total',
|
||||||
|
}
|
||||||
|
|
||||||
|
def redact_value(v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return '—'
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
redacted = {}
|
||||||
|
for k, v in order.items():
|
||||||
|
if k in sensitive:
|
||||||
|
redacted[k] = redact_value(v)
|
||||||
|
elif k == 'items' and isinstance(v, list):
|
||||||
|
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
|
||||||
|
else:
|
||||||
|
redacted[k] = v
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_item_for_mechanic(item):
|
||||||
|
"""Hide mechanic name from item rows for mechanics."""
|
||||||
|
item = dict(item)
|
||||||
|
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||||
|
if k in item:
|
||||||
|
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def require_workshop_view(f):
|
||||||
|
@wraps(f)
|
||||||
|
@require_auth()
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not _can_view_workshop():
|
||||||
|
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def require_workshop_edit(f):
|
||||||
|
@wraps(f)
|
||||||
|
@require_auth()
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not _can_edit_workshop():
|
||||||
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('', methods=['GET'])
|
@service_order_bp.route('', methods=['GET'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_view
|
||||||
def list_orders():
|
def list_orders():
|
||||||
status = request.args.get('status')
|
status = request.args.get('status')
|
||||||
priority = request.args.get('priority')
|
priority = request.args.get('priority')
|
||||||
@@ -56,6 +141,14 @@ def list_orders():
|
|||||||
employee_id=employee_id, delivery_method=delivery_method,
|
employee_id=employee_id, delivery_method=delivery_method,
|
||||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||||
)
|
)
|
||||||
|
if _is_restricted_workshop_viewer():
|
||||||
|
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
|
||||||
|
# Shared mechanic account can see all orders except commercial/closed statuses.
|
||||||
|
if g.employee_role == 'mechanic':
|
||||||
|
result['data'] = [
|
||||||
|
o for o in result.get('data', [])
|
||||||
|
if o.get('status') not in _MECHANIC_HIDDEN_STATUSES
|
||||||
|
]
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -88,6 +181,11 @@ def create_order_from_pos():
|
|||||||
'delivery_method': data.get('delivery_method'),
|
'delivery_method': data.get('delivery_method'),
|
||||||
'courier_id': data.get('courier_id'),
|
'courier_id': data.get('courier_id'),
|
||||||
'is_direct': data.get('is_direct', False),
|
'is_direct': data.get('is_direct', False),
|
||||||
|
'requires_invoice': data.get('requires_invoice', False),
|
||||||
|
'workshop_name': data.get('workshop_name'),
|
||||||
|
'customer_address': data.get('customer_address'),
|
||||||
|
'customer_phone': data.get('customer_phone'),
|
||||||
|
'vehicle_description': data.get('vehicle_description'),
|
||||||
})
|
})
|
||||||
so_id = result['service_order_id']
|
so_id = result['service_order_id']
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -99,7 +197,7 @@ def create_order_from_pos():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('', methods=['POST'])
|
@service_order_bp.route('', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def create_order():
|
def create_order():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -120,6 +218,11 @@ def create_order():
|
|||||||
'delivery_method': data.get('delivery_method'),
|
'delivery_method': data.get('delivery_method'),
|
||||||
'courier_id': data.get('courier_id'),
|
'courier_id': data.get('courier_id'),
|
||||||
'is_direct': data.get('is_direct', False),
|
'is_direct': data.get('is_direct', False),
|
||||||
|
'requires_invoice': data.get('requires_invoice', False),
|
||||||
|
'workshop_name': data.get('workshop_name'),
|
||||||
|
'customer_address': data.get('customer_address'),
|
||||||
|
'customer_phone': data.get('customer_phone'),
|
||||||
|
'vehicle_description': data.get('vehicle_description'),
|
||||||
})
|
})
|
||||||
return jsonify(result), 201
|
return jsonify(result), 201
|
||||||
finally:
|
finally:
|
||||||
@@ -127,22 +230,33 @@ def create_order():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_view
|
||||||
def get_order(so_id):
|
def get_order(so_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
order = get_service_order(conn, so_id)
|
order = get_service_order(conn, so_id)
|
||||||
if not order:
|
if not order:
|
||||||
return jsonify({'error': 'Service order not found'}), 404
|
return jsonify({'error': 'Service order not found'}), 404
|
||||||
return jsonify(order)
|
# Shared mechanic account cannot view commercial/closed statuses.
|
||||||
|
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
||||||
|
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||||
|
return jsonify(_redact_order_for_mechanic(order))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_view
|
||||||
def update_order(so_id):
|
def update_order(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
# Full edit remains for owner/admin/counter/cashier.
|
||||||
|
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
||||||
|
if g.employee_role == 'mechanic':
|
||||||
|
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
||||||
|
if not data or any(k not in allowed for k in data.keys()):
|
||||||
|
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
||||||
|
elif not _can_edit_workshop():
|
||||||
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
ok = update_service_order(conn, so_id, data)
|
ok = update_service_order(conn, so_id, data)
|
||||||
@@ -154,7 +268,7 @@ def update_order(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def delete_order(so_id):
|
def delete_order(so_id):
|
||||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
if g.employee_role not in ('owner', 'admin'):
|
||||||
@@ -171,12 +285,17 @@ def delete_order(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_view
|
||||||
def change_status(so_id):
|
def change_status(so_id):
|
||||||
|
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
|
||||||
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
new_status = data.get('status')
|
new_status = data.get('status')
|
||||||
if not new_status:
|
if not new_status:
|
||||||
return jsonify({'error': 'status is required'}), 400
|
return jsonify({'error': 'status is required'}), 400
|
||||||
|
# Mechanics cannot move orders into commercial/closed statuses.
|
||||||
|
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
|
||||||
|
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
result = update_status(
|
result = update_status(
|
||||||
@@ -194,7 +313,7 @@ def change_status(so_id):
|
|||||||
# ─── Items (Parts) ─────────────────────────────
|
# ─── Items (Parts) ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def add_order_item(so_id):
|
def add_order_item(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -206,7 +325,7 @@ def add_order_item(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def update_order_item(item_id):
|
def update_order_item(item_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -220,7 +339,7 @@ def update_order_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def delete_order_item(item_id):
|
def delete_order_item(item_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -233,7 +352,7 @@ def delete_order_item(item_id):
|
|||||||
# ─── Labor ─────────────────────────────
|
# ─── Labor ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def add_order_labor(so_id):
|
def add_order_labor(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('description'):
|
if not data.get('description'):
|
||||||
@@ -247,7 +366,7 @@ def add_order_labor(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def update_order_labor(labor_id):
|
def update_order_labor(labor_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -261,7 +380,7 @@ def update_order_labor(labor_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def delete_order_labor(labor_id):
|
def delete_order_labor(labor_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -274,7 +393,7 @@ def delete_order_labor(labor_id):
|
|||||||
# ─── Kanban Summary ─────────────────────────────
|
# ─── Kanban Summary ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_view
|
||||||
def kanban_summary():
|
def kanban_summary():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -288,7 +407,7 @@ def kanban_summary():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def reserve_order_item(so_id, item_id):
|
def reserve_order_item(so_id, item_id):
|
||||||
"""Reserve inventory for a service order item."""
|
"""Reserve inventory for a service order item."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -302,7 +421,7 @@ def reserve_order_item(so_id, item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def release_order_item(so_id, item_id):
|
def release_order_item(so_id, item_id):
|
||||||
"""Release a previous inventory reservation."""
|
"""Release a previous inventory reservation."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -354,11 +473,42 @@ def convert_order_to_sale(so_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Convert to remission note ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||||
|
@require_auth('pos.remission')
|
||||||
|
def convert_order_to_remission(so_id):
|
||||||
|
"""Convert a service order into a counter remission note (pending payment).
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
register_id: int (optional),
|
||||||
|
notes: str (optional)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
sale_payload = {
|
||||||
|
'register_id': data.get('register_id'),
|
||||||
|
'notes': data.get('notes'),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = convert_to_remission(
|
||||||
|
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||||
|
)
|
||||||
|
return jsonify(result), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ─── Mechanic assignment ──────────────────────────
|
# ─── Mechanic assignment ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def assign_mechanic_endpoint(so_id):
|
def assign_mechanic_endpoint(so_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -377,7 +527,7 @@ def assign_mechanic_endpoint(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/customers', methods=['POST'])
|
@service_order_bp.route('/customers', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def create_customer_for_workshop():
|
def create_customer_for_workshop():
|
||||||
"""Create a customer directly from the workshop flow."""
|
"""Create a customer directly from the workshop flow."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -411,7 +561,7 @@ def create_customer_for_workshop():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_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.
|
||||||
|
|
||||||
@@ -456,7 +606,7 @@ def create_vehicle_for_workshop():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
@service_order_bp.route('/inventory-search', methods=['GET'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_view
|
||||||
def inventory_search():
|
def inventory_search():
|
||||||
"""Search active inventory for attaching parts to a service order.
|
"""Search active inventory for attaching parts to a service order.
|
||||||
|
|
||||||
@@ -513,7 +663,7 @@ def inventory_search():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_view
|
||||||
def list_catalog():
|
def list_catalog():
|
||||||
"""List reusable labor/service concepts."""
|
"""List reusable labor/service concepts."""
|
||||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||||
@@ -526,7 +676,7 @@ def list_catalog():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def create_catalog_item():
|
def create_catalog_item():
|
||||||
"""Create a reusable labor concept."""
|
"""Create a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -542,7 +692,7 @@ def create_catalog_item():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def update_catalog_item(item_id):
|
def update_catalog_item(item_id):
|
||||||
"""Update a reusable labor concept."""
|
"""Update a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -557,7 +707,7 @@ def update_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||||
@require_auth('workshop.edit')
|
@require_workshop_edit
|
||||||
def delete_catalog_item(item_id):
|
def delete_catalog_item(item_id):
|
||||||
"""Soft-delete a reusable labor concept."""
|
"""Soft-delete a reusable labor concept."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -572,7 +722,7 @@ def delete_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||||
@require_auth('workshop.view')
|
@require_workshop_edit
|
||||||
def print_service_order_ticket(so_id):
|
def print_service_order_ticket(so_id):
|
||||||
"""Generate a printable ticket for a service order.
|
"""Generate a printable ticket for a service order.
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
|||||||
# Check permissions
|
# Check permissions
|
||||||
if required_permissions:
|
if required_permissions:
|
||||||
missing = set(required_permissions) - g.permissions
|
missing = set(required_permissions) - g.permissions
|
||||||
# owner role bypasses all permission checks
|
# owner/admin roles bypass all permission checks
|
||||||
if g.employee_role != 'owner' and missing:
|
if g.employee_role not in ('owner', 'admin') and missing:
|
||||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
|||||||
|
|
||||||
def has_permission(permission):
|
def has_permission(permission):
|
||||||
"""Check if current user has a specific permission. Use inside a route."""
|
"""Check if current user has a specific permission. Use inside a route."""
|
||||||
return g.employee_role == 'owner' or permission in g.permissions
|
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ MIGRATIONS = {
|
|||||||
"v4.8": "v4.8_workshop_permissions.sql",
|
"v4.8": "v4.8_workshop_permissions.sql",
|
||||||
"v4.9": "v4.9_workshop_customers_view.sql",
|
"v4.9": "v4.9_workshop_customers_view.sql",
|
||||||
"v4.10": "v4.10_fleet_permissions.sql",
|
"v4.10": "v4.10_fleet_permissions.sql",
|
||||||
|
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
|
||||||
|
"v4.12": "v4.12_service_order_soft_delete.sql",
|
||||||
|
"v4.13": "v4.13_fleet_vehicle_customer.sql",
|
||||||
|
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
|
||||||
|
"v4.15": "v4.15_counter_remission.sql",
|
||||||
|
"v4.16": "v4.16_remission_courier.sql",
|
||||||
|
"v4.17": "v4.17_service_order_invoice.sql",
|
||||||
|
"v4.18": "v4.18_rached_workshop.sql",
|
||||||
|
"v4.19": "v4.19_counter_inventory_create.sql",
|
||||||
|
"v4.20": "v4.20_cashier_sell_permissions.sql",
|
||||||
|
"v4.21": "v4.21_cashier_workshop_permissions.sql",
|
||||||
|
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
|
||||||
|
"v4.23": "v4.23_service_order_mechanic_name.sql",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -112,7 +125,10 @@ def apply_migration(db_name, version):
|
|||||||
def run_migrations():
|
def run_migrations():
|
||||||
"""Apply pending migrations to all tenants."""
|
"""Apply pending migrations to all tenants."""
|
||||||
tenants = get_all_tenants()
|
tenants = get_all_tenants()
|
||||||
sorted_versions = sorted(MIGRATIONS.keys())
|
def _version_key(v):
|
||||||
|
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
||||||
|
|
||||||
|
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
||||||
|
|
||||||
print(f"Found {len(tenants)} active tenants")
|
print(f"Found {len(tenants)} active tenants")
|
||||||
print(f"Available migrations: {sorted_versions}")
|
print(f"Available migrations: {sorted_versions}")
|
||||||
@@ -120,8 +136,9 @@ def run_migrations():
|
|||||||
for tenant_id, db_name, name, current_version in tenants:
|
for tenant_id, db_name, name, current_version in tenants:
|
||||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||||
|
|
||||||
|
current_key = _version_key(current_version)
|
||||||
for version in sorted_versions:
|
for version in sorted_versions:
|
||||||
if version <= current_version:
|
if _version_key(version) <= current_key:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" Applying {version}...", end=" ")
|
print(f" Applying {version}...", end=" ")
|
||||||
|
|||||||
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
|
||||||
|
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
|
||||||
|
|
||||||
|
UPDATE service_orders
|
||||||
|
SET delivery_method = 'pickup'
|
||||||
|
WHERE delivery_method = 'courier';
|
||||||
|
|
||||||
|
UPDATE service_orders
|
||||||
|
SET courier_id = NULL
|
||||||
|
WHERE delivery_method IS NULL OR delivery_method != 'delivery';
|
||||||
18
pos/migrations/v4.15_counter_remission.sql
Normal file
18
pos/migrations/v4.15_counter_remission.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- v4.15: Counter remission note support
|
||||||
|
-- Ensures employees with role 'counter' have the base permissions needed to
|
||||||
|
-- generate remission notes from the POS.
|
||||||
|
|
||||||
|
INSERT INTO employee_permissions (employee_id, permission)
|
||||||
|
SELECT e.id, p.permission
|
||||||
|
FROM employees e
|
||||||
|
CROSS JOIN (
|
||||||
|
VALUES
|
||||||
|
('pos.remission'),
|
||||||
|
('pos.view'),
|
||||||
|
('catalog.view'),
|
||||||
|
('inventory.view'),
|
||||||
|
('customers.view'),
|
||||||
|
('customers.create')
|
||||||
|
) AS p(permission)
|
||||||
|
WHERE e.role = 'counter'
|
||||||
|
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||||
18
pos/migrations/v4.16_remission_courier.sql
Normal file
18
pos/migrations/v4.16_remission_courier.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- v4.16: Add courier assignment to counter remission notes.
|
||||||
|
|
||||||
|
ALTER TABLE sales
|
||||||
|
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'sales_courier_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sales
|
||||||
|
ADD CONSTRAINT sales_courier_id_fkey
|
||||||
|
FOREIGN KEY (courier_id) REFERENCES couriers(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);
|
||||||
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Service order invoice requirement flag
|
||||||
|
ALTER TABLE service_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
|
||||||
|
ON service_orders(requires_invoice);
|
||||||
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- v4.18 Rached workshop fields
|
||||||
|
-- Extends service orders and items to match the Rached legacy workshop flow.
|
||||||
|
-- Applied to all tenants.
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
ALTER TABLE service_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_address TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
|
||||||
|
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
|
||||||
|
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
|
||||||
|
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
ALTER TABLE service_order_items
|
||||||
|
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS observations TEXT;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
|
||||||
|
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);
|
||||||
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- v4.19 — Grant inventory.create permission to existing counter employees
|
||||||
|
-- so they can create items and record purchase entries.
|
||||||
|
INSERT INTO employee_permissions (employee_id, permission)
|
||||||
|
SELECT e.id, 'inventory.create'
|
||||||
|
FROM employees e
|
||||||
|
WHERE e.role = 'counter'
|
||||||
|
AND e.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM employee_permissions ep
|
||||||
|
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
|
||||||
|
);
|
||||||
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
|
||||||
|
-- The cashier default set already includes these permissions, but employees
|
||||||
|
-- created before the fix may be missing them.
|
||||||
|
INSERT INTO employee_permissions (employee_id, permission)
|
||||||
|
SELECT e.id, p.perm
|
||||||
|
FROM employees e
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('pos.sell'),
|
||||||
|
('pos.discount'),
|
||||||
|
('pos.cancel'),
|
||||||
|
('pos.view')
|
||||||
|
) AS p(perm)
|
||||||
|
WHERE e.role = 'cashier'
|
||||||
|
AND e.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM employee_permissions ep
|
||||||
|
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||||
|
);
|
||||||
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- v4.21 — Allow existing cashier employees to create and edit service orders.
|
||||||
|
INSERT INTO employee_permissions (employee_id, permission)
|
||||||
|
SELECT e.id, p.perm
|
||||||
|
FROM employees e
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('workshop.view'),
|
||||||
|
('workshop.edit'),
|
||||||
|
('workshop.add_items')
|
||||||
|
) AS p(perm)
|
||||||
|
WHERE e.role = 'cashier'
|
||||||
|
AND e.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM employee_permissions ep
|
||||||
|
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||||
|
);
|
||||||
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- v4.22 -- Enable invoicing for existing cashier employees.
|
||||||
|
INSERT INTO employee_permissions (employee_id, permission)
|
||||||
|
SELECT e.id, p.perm
|
||||||
|
FROM employees e
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('invoicing.view'),
|
||||||
|
('invoicing.create'),
|
||||||
|
('invoicing.cancel')
|
||||||
|
) AS p(perm)
|
||||||
|
WHERE e.role = 'cashier'
|
||||||
|
AND e.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM employee_permissions ep
|
||||||
|
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||||
|
);
|
||||||
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- v4.23 -- Add free-text mechanic name for service orders.
|
||||||
|
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;
|
||||||
@@ -230,6 +230,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
|||||||
return op_id
|
return op_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
||||||
|
cost_at_time=None, remaining_stock=None):
|
||||||
|
"""Reserve stock for a counter remission note (negative quantity).
|
||||||
|
|
||||||
|
The reserved quantity is deducted from available stock until the note is
|
||||||
|
paid or cancelled.
|
||||||
|
"""
|
||||||
|
op_id = record_operation(
|
||||||
|
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
||||||
|
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
||||||
|
notes='Reserva por nota de remision'
|
||||||
|
)
|
||||||
|
invalidate_stock(inventory_id, branch_id)
|
||||||
|
invalidate_stock(inventory_id, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||||
|
if remaining <= 0:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||||
|
inv_row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if inv_row:
|
||||||
|
from services.push_service import notify_owner
|
||||||
|
notify_owner(
|
||||||
|
conn,
|
||||||
|
'Stock en Cero',
|
||||||
|
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
||||||
|
'/pos'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return op_id
|
||||||
|
|
||||||
|
|
||||||
|
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||||
|
"""Release a previous reservation (positive quantity)."""
|
||||||
|
result = record_operation(
|
||||||
|
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
||||||
|
reference_id=sale_id, reference_type='sale',
|
||||||
|
notes=notes or 'Liberacion de reserva de nota de remision'
|
||||||
|
)
|
||||||
|
invalidate_stock(inventory_id, branch_id)
|
||||||
|
invalidate_stock(inventory_id, None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||||
"""Record a customer return (positive quantity)."""
|
"""Record a customer return (positive quantity)."""
|
||||||
result = record_operation(
|
result = record_operation(
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from flask import g
|
|||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from services.inventory_engine import (
|
from services.inventory_engine import (
|
||||||
record_sale as inventory_record_sale,
|
record_sale as inventory_record_sale,
|
||||||
|
record_reservation as inventory_record_reservation,
|
||||||
|
release_reservation as inventory_release_reservation,
|
||||||
record_operation,
|
record_operation,
|
||||||
get_stock,
|
get_stock,
|
||||||
get_stock_bulk,
|
get_stock_bulk,
|
||||||
@@ -313,24 +315,34 @@ def process_sale(conn, sale_data):
|
|||||||
credit_limit = float(cust[0] or 0)
|
credit_limit = float(cust[0] or 0)
|
||||||
credit_balance = float(cust[1] or 0)
|
credit_balance = float(cust[1] or 0)
|
||||||
credit_available = credit_limit - credit_balance
|
credit_available = credit_limit - credit_balance
|
||||||
if totals['total'] > credit_available and credit_limit > 0:
|
if totals['total'] > credit_available:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||||
f"Required: ${totals['total']:.2f}"
|
f"Required: ${totals['total']:.2f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pending payment sale (e.g. "Pendiente")
|
||||||
|
is_pending = payment_method == 'pendiente'
|
||||||
|
if is_pending:
|
||||||
|
amount_paid = 0.0
|
||||||
|
sale_type = 'cash'
|
||||||
|
|
||||||
# Calculate change
|
# Calculate change
|
||||||
change_given = 0.0
|
change_given = 0.0
|
||||||
if sale_type == 'cash' and payment_method == 'efectivo':
|
if sale_type == 'cash' and payment_method == 'efectivo':
|
||||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||||
|
|
||||||
# SAT payment method codes
|
# SAT payment method codes
|
||||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
|
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
|
||||||
forma_pago_map = {
|
forma_pago_map = {
|
||||||
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||||
|
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||||
}
|
}
|
||||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||||
|
|
||||||
|
# Determine sale status
|
||||||
|
status = 'pending_payment' if is_pending else 'completed'
|
||||||
|
|
||||||
# Create sale record (with currency)
|
# Create sale record (with currency)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sales
|
INSERT INTO sales
|
||||||
@@ -338,14 +350,14 @@ def process_sale(conn, sale_data):
|
|||||||
payment_method, subtotal, discount_total, tax_total, total,
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||||
status, device_id, notes, currency, exchange_rate)
|
status, device_id, notes, currency, exchange_rate)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
""", (
|
""", (
|
||||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
payment_method, totals['subtotal'], totals['discount_total'],
|
payment_method, totals['subtotal'], totals['discount_total'],
|
||||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||||
metodo_pago_sat, forma_pago_sat,
|
metodo_pago_sat, forma_pago_sat,
|
||||||
_safe_g('device_id'), notes,
|
status, _safe_g('device_id'), notes,
|
||||||
currency, exchange_rate
|
currency, exchange_rate
|
||||||
))
|
))
|
||||||
sale_id, created_at = cur.fetchone()
|
sale_id, created_at = cur.fetchone()
|
||||||
@@ -404,23 +416,26 @@ def process_sale(conn, sale_data):
|
|||||||
'subtotal': item['subtotal'],
|
'subtotal': item['subtotal'],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Record payment on cash register (cash movements for efectivo)
|
# Record payment on cash register (skip pending sales and zero-amount rows)
|
||||||
if register_id and payment_details:
|
if not is_pending:
|
||||||
for pd in payment_details:
|
if register_id and payment_details:
|
||||||
method = pd.get('method', payment_method)
|
for pd in payment_details:
|
||||||
amt = float(pd.get('amount', 0))
|
method = pd.get('method', payment_method)
|
||||||
ref = pd.get('reference', '')
|
amt = float(pd.get('amount', 0))
|
||||||
|
ref = pd.get('reference', '')
|
||||||
|
if amt <= 0:
|
||||||
|
continue
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_payments
|
||||||
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||||
|
elif register_id and amount_paid > 0:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sale_payments
|
INSERT INTO sale_payments
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||||
elif register_id:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sale_payments
|
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
|
||||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
|
||||||
|
|
||||||
# Update customer credit balance if credit sale
|
# Update customer credit balance if credit sale
|
||||||
if sale_type == 'credit' and customer_id:
|
if sale_type == 'credit' and customer_id:
|
||||||
@@ -430,6 +445,16 @@ def process_sale(conn, sale_data):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (totals['total'], customer_id))
|
""", (totals['total'], customer_id))
|
||||||
|
|
||||||
|
# Fetch customer info for ticket/receipt
|
||||||
|
customer_name = None
|
||||||
|
customer_rfc = None
|
||||||
|
if customer_id:
|
||||||
|
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust_row = cur.fetchone()
|
||||||
|
if cust_row:
|
||||||
|
customer_name = cust_row[0]
|
||||||
|
customer_rfc = cust_row[1]
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
log_action(conn, 'SALE', 'sale', sale_id,
|
log_action(conn, 'SALE', 'sale', sale_id,
|
||||||
new_value={
|
new_value={
|
||||||
@@ -506,6 +531,8 @@ def process_sale(conn, sale_data):
|
|||||||
'id': sale_id,
|
'id': sale_id,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
'customer_id': customer_id,
|
'customer_id': customer_id,
|
||||||
|
'customer_name': customer_name,
|
||||||
|
'customer_rfc': customer_rfc,
|
||||||
'employee_id': employee_id,
|
'employee_id': employee_id,
|
||||||
'register_id': register_id,
|
'register_id': register_id,
|
||||||
'sale_type': sale_type,
|
'sale_type': sale_type,
|
||||||
@@ -526,6 +553,342 @@ def process_sale(conn, sale_data):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_remission_note(conn, data):
|
||||||
|
"""Create a counter remission note: reserve stock, no payment, pending status.
|
||||||
|
|
||||||
|
data: {
|
||||||
|
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
||||||
|
customer_id: int | null,
|
||||||
|
notes: str,
|
||||||
|
branch_id: int,
|
||||||
|
register_id: int | null (optional)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
items = data.get('items', [])
|
||||||
|
customer_id = data.get('customer_id')
|
||||||
|
notes = data.get('notes')
|
||||||
|
branch_id = data.get('branch_id') or _safe_g('branch_id')
|
||||||
|
register_id = data.get('register_id')
|
||||||
|
employee_id = _safe_g('employee_id')
|
||||||
|
currency = data.get('currency', 'MXN')
|
||||||
|
if currency not in ('MXN', 'USD'):
|
||||||
|
raise ValueError("Unsupported currency")
|
||||||
|
exchange_rate = float(data.get('exchange_rate') or 1.0)
|
||||||
|
if currency != 'MXN' and not exchange_rate:
|
||||||
|
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||||
|
|
||||||
|
if not branch_id:
|
||||||
|
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
branch_id = row[0] if row else None
|
||||||
|
if not branch_id:
|
||||||
|
raise ValueError("No hay sucursal activa disponible")
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise ValueError("No items in remission note")
|
||||||
|
|
||||||
|
inv_ids = [item.get('inventory_id') for item in items]
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||||
|
tax_rate, branch_id, retail_price
|
||||||
|
FROM inventory
|
||||||
|
WHERE id = ANY(%s) AND is_active = true
|
||||||
|
ORDER BY id
|
||||||
|
FOR UPDATE
|
||||||
|
""", (inv_ids,))
|
||||||
|
inv_rows = {r[0]: r for r in cur.fetchall()}
|
||||||
|
|
||||||
|
stock_map = get_stock_bulk(conn, branch_id)
|
||||||
|
if branch_id:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT inventory_id, stock
|
||||||
|
FROM inventory_stock
|
||||||
|
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
||||||
|
FOR UPDATE
|
||||||
|
""", (branch_id, inv_ids))
|
||||||
|
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
enriched_items = []
|
||||||
|
for item in items:
|
||||||
|
inv_id = item.get('inventory_id')
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
if qty <= 0:
|
||||||
|
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||||
|
inv = inv_rows.get(inv_id)
|
||||||
|
if not inv:
|
||||||
|
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||||
|
current_stock = stock_map.get(inv_id, 0)
|
||||||
|
unit_price = float(item.get('unit_price', inv[4]))
|
||||||
|
discount_pct = float(item.get('discount_pct', 0))
|
||||||
|
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
||||||
|
unit_cost = float(inv[3]) if inv[3] else 0
|
||||||
|
enriched_items.append({
|
||||||
|
'inventory_id': inv_id,
|
||||||
|
'part_number': inv[1],
|
||||||
|
'name': inv[2],
|
||||||
|
'quantity': qty,
|
||||||
|
'unit_price': unit_price,
|
||||||
|
'unit_cost': unit_cost,
|
||||||
|
'discount_pct': discount_pct,
|
||||||
|
'tax_rate': tax_rate,
|
||||||
|
'branch_id': inv[8],
|
||||||
|
'stock_before': current_stock,
|
||||||
|
})
|
||||||
|
|
||||||
|
totals = calculate_totals(enriched_items)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sales
|
||||||
|
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
|
||||||
|
courier_id)
|
||||||
|
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
|
||||||
|
%s, %s, %s, %s, 0, 0, 'pending_payment',
|
||||||
|
%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""", (
|
||||||
|
branch_id, customer_id, employee_id, register_id,
|
||||||
|
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
|
||||||
|
_safe_g('device_id'), notes, currency, exchange_rate,
|
||||||
|
data.get('courier_id')
|
||||||
|
))
|
||||||
|
sale_id, created_at = cur.fetchone()
|
||||||
|
|
||||||
|
sale_items_data = []
|
||||||
|
for item in totals['items']:
|
||||||
|
inv = inv_rows.get(item['inventory_id'])
|
||||||
|
retail_price = inv[9] if inv else None
|
||||||
|
sale_items_data.append((
|
||||||
|
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||||
|
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
||||||
|
item['discount_pct'], item['discount_amount'],
|
||||||
|
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
||||||
|
retail_price, currency, exchange_rate
|
||||||
|
))
|
||||||
|
|
||||||
|
cur.executemany("""
|
||||||
|
INSERT INTO sale_items
|
||||||
|
(sale_id, inventory_id, part_number, name, quantity,
|
||||||
|
unit_price, unit_cost, discount_pct, discount_amount,
|
||||||
|
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", sale_items_data)
|
||||||
|
|
||||||
|
sale_items = []
|
||||||
|
for item in totals['items']:
|
||||||
|
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
||||||
|
remaining_after = stock_before - item['quantity']
|
||||||
|
inventory_record_reservation(
|
||||||
|
conn,
|
||||||
|
item['inventory_id'],
|
||||||
|
item.get('branch_id', branch_id),
|
||||||
|
item['quantity'],
|
||||||
|
sale_id=sale_id,
|
||||||
|
cost_at_time=item.get('unit_cost'),
|
||||||
|
remaining_stock=remaining_after
|
||||||
|
)
|
||||||
|
sale_items.append({
|
||||||
|
'inventory_id': item['inventory_id'],
|
||||||
|
'part_number': item['part_number'],
|
||||||
|
'name': item['name'],
|
||||||
|
'quantity': item['quantity'],
|
||||||
|
'unit_price': item['unit_price'],
|
||||||
|
'unit_cost': item.get('unit_cost', 0),
|
||||||
|
'discount_pct': item['discount_pct'],
|
||||||
|
'discount_amount': item['discount_amount'],
|
||||||
|
'tax_rate': item['tax_rate'],
|
||||||
|
'tax_amount': item['tax_amount'],
|
||||||
|
'subtotal': item['subtotal'],
|
||||||
|
})
|
||||||
|
|
||||||
|
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
|
||||||
|
new_value={
|
||||||
|
'total': totals['total'],
|
||||||
|
'items_count': len(sale_items),
|
||||||
|
'customer_id': customer_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fetch customer info for ticket/receipt
|
||||||
|
customer_name = None
|
||||||
|
customer_rfc = None
|
||||||
|
if customer_id:
|
||||||
|
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust_row = cur.fetchone()
|
||||||
|
if cust_row:
|
||||||
|
customer_name = cust_row[0]
|
||||||
|
customer_rfc = cust_row[1]
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return {
|
||||||
|
'id': sale_id,
|
||||||
|
'branch_id': branch_id,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'customer_name': customer_name,
|
||||||
|
'customer_rfc': customer_rfc,
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'register_id': register_id,
|
||||||
|
'sale_type': 'counter_remission',
|
||||||
|
'payment_method': 'remission',
|
||||||
|
'subtotal': totals['subtotal'],
|
||||||
|
'discount_total': totals['discount_total'],
|
||||||
|
'tax_total': totals['tax_total'],
|
||||||
|
'total': totals['total'],
|
||||||
|
'amount_paid': 0.0,
|
||||||
|
'change_given': 0.0,
|
||||||
|
'status': 'pending_payment',
|
||||||
|
'courier_id': data.get('courier_id'),
|
||||||
|
'items': sale_items,
|
||||||
|
'created_at': str(created_at),
|
||||||
|
'currency': currency,
|
||||||
|
'exchange_rate': exchange_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pay_pending_sale(conn, sale_id, data):
|
||||||
|
"""Pay a pending counter remission note and convert it into a completed sale.
|
||||||
|
|
||||||
|
data: {
|
||||||
|
payment_method: str,
|
||||||
|
amount_paid: float,
|
||||||
|
payment_details: [{method, amount, reference}],
|
||||||
|
register_id: int,
|
||||||
|
reference: str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||||
|
currency, exchange_rate
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sale not found")
|
||||||
|
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||||
|
currency, exchange_rate) = row
|
||||||
|
|
||||||
|
# Fetch customer info for ticket/receipt
|
||||||
|
customer_name = None
|
||||||
|
customer_rfc = None
|
||||||
|
if customer_id:
|
||||||
|
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust_row = cur.fetchone()
|
||||||
|
if cust_row:
|
||||||
|
customer_name = cust_row[0]
|
||||||
|
customer_rfc = cust_row[1]
|
||||||
|
|
||||||
|
if status != 'pending_payment':
|
||||||
|
raise ValueError("La nota no esta pendiente de pago")
|
||||||
|
|
||||||
|
payment_method = data.get('payment_method', 'efectivo')
|
||||||
|
sale_type = 'cash'
|
||||||
|
amount_paid = float(data.get('amount_paid', 0))
|
||||||
|
payment_details = data.get('payment_details', [])
|
||||||
|
register_id = data.get('register_id')
|
||||||
|
reference = data.get('reference', '')
|
||||||
|
|
||||||
|
if register_id:
|
||||||
|
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
if not reg or reg[0] != 'open':
|
||||||
|
raise ValueError("Cash register is not open")
|
||||||
|
|
||||||
|
totals = {'total': float(total)}
|
||||||
|
change_given = 0.0
|
||||||
|
if payment_method == 'efectivo':
|
||||||
|
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||||
|
|
||||||
|
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
|
||||||
|
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE sales
|
||||||
|
SET status = 'completed',
|
||||||
|
sale_type = %s,
|
||||||
|
payment_method = %s,
|
||||||
|
amount_paid = %s,
|
||||||
|
change_given = %s,
|
||||||
|
register_id = COALESCE(%s, register_id),
|
||||||
|
metodo_pago_sat = 'PUE',
|
||||||
|
forma_pago_sat = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
|
||||||
|
|
||||||
|
if payment_details:
|
||||||
|
for pd in payment_details:
|
||||||
|
method = pd.get('method', payment_method)
|
||||||
|
amt = float(pd.get('amount', 0))
|
||||||
|
ref = pd.get('reference', '')
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_payments
|
||||||
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_payments
|
||||||
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
|
||||||
|
|
||||||
|
# Release reservation and record actual sale movement
|
||||||
|
cur.execute("""
|
||||||
|
SELECT inventory_id, quantity, unit_cost
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
for inv_id, qty, cost in cur.fetchall():
|
||||||
|
inventory_release_reservation(
|
||||||
|
conn, inv_id, branch_id, qty, sale_id=sale_id,
|
||||||
|
notes='Pago de nota de remision'
|
||||||
|
)
|
||||||
|
inventory_record_sale(
|
||||||
|
conn, inv_id, branch_id, qty,
|
||||||
|
sale_id=sale_id, cost_at_time=float(cost) if cost else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accounting (non-blocking)
|
||||||
|
try:
|
||||||
|
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
|
||||||
|
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
|
||||||
|
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(SUM(unit_cost * quantity), 0)
|
||||||
|
FROM sale_items WHERE sale_id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
cost_total = float(cur.fetchone()[0] or 0)
|
||||||
|
record_sale_entry(conn, {
|
||||||
|
'id': sale_id,
|
||||||
|
'sale_type': sale_type,
|
||||||
|
'total': total_mxn,
|
||||||
|
'tax_total': tax_mxn,
|
||||||
|
'subtotal': sub_mxn,
|
||||||
|
'cost_total': cost_total,
|
||||||
|
'payment_method': payment_method,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
||||||
|
old_value={'status': 'pending_payment', 'total': totals['total']},
|
||||||
|
new_value={'status': 'completed', 'payment_method': payment_method})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return {
|
||||||
|
'id': sale_id,
|
||||||
|
'status': 'completed',
|
||||||
|
'payment_method': payment_method,
|
||||||
|
'amount_paid': amount_paid,
|
||||||
|
'change_given': change_given,
|
||||||
|
'total': totals['total'],
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'customer_name': customer_name,
|
||||||
|
'customer_rfc': customer_rfc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cancel_sale(conn, sale_id, reason):
|
def cancel_sale(conn, sale_id, reason):
|
||||||
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||||
|
|
||||||
@@ -567,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
if s_status == 'cancelled':
|
if s_status == 'cancelled':
|
||||||
raise ValueError("Sale is already cancelled")
|
raise ValueError("Sale is already cancelled")
|
||||||
|
|
||||||
# Permission check: cashiers can only cancel own sales within 30 min
|
# Permission check: non-admin employees can only cancel their own docs within 30 min
|
||||||
role = _safe_g('employee_role', 'cashier')
|
role = _safe_g('employee_role', 'cashier')
|
||||||
emp_id = _safe_g('employee_id')
|
emp_id = _safe_g('employee_id')
|
||||||
|
|
||||||
if role == 'cashier':
|
if role not in ('owner', 'admin'):
|
||||||
if s_emp_id != emp_id:
|
if s_emp_id != emp_id:
|
||||||
raise ValueError("Cashiers can only cancel their own sales")
|
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
|
||||||
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||||
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
|
||||||
|
|
||||||
# Get sale items for inventory reversal
|
# Get sale items for inventory reversal
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -584,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
""", (sale_id,))
|
""", (sale_id,))
|
||||||
sale_items = cur.fetchall()
|
sale_items = cur.fetchall()
|
||||||
|
|
||||||
# Reverse inventory: create RETURN operations (positive quantity)
|
if s_status == 'pending_payment':
|
||||||
from services.inventory_engine import record_return
|
# Pending remission: release reservation
|
||||||
for inv_id, qty, cost in sale_items:
|
for inv_id, qty, cost in sale_items:
|
||||||
record_return(
|
inventory_release_reservation(
|
||||||
conn, inv_id, s_branch, qty,
|
conn, inv_id, s_branch, qty,
|
||||||
sale_id=sale_id,
|
sale_id=sale_id,
|
||||||
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Completed sale: create RETURN operations (positive quantity)
|
||||||
|
from services.inventory_engine import record_return
|
||||||
|
for inv_id, qty, cost in sale_items:
|
||||||
|
record_return(
|
||||||
|
conn, inv_id, s_branch, qty,
|
||||||
|
sale_id=sale_id,
|
||||||
|
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
# Update sale status
|
# Update sale status
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -627,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
log_action(conn, 'CANCEL', 'sale', sale_id,
|
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||||
old_value={'status': 'completed', 'total': float(s_total)},
|
old_value={'status': s_status, 'total': float(s_total)},
|
||||||
new_value={'status': 'cancelled', 'reason': reason})
|
new_value={'status': 'cancelled', 'reason': reason})
|
||||||
|
|
||||||
# Push notification to owner/admin (best-effort, non-blocking)
|
# Push notification to owner/admin (best-effort, non-blocking)
|
||||||
|
|||||||
@@ -8,15 +8,43 @@ from datetime import datetime
|
|||||||
|
|
||||||
from services import inventory_engine
|
from services import inventory_engine
|
||||||
|
|
||||||
|
# Rached workshop statuses (applies to all tenants).
|
||||||
|
ORDER_STATUSES = [
|
||||||
|
'por_revisar',
|
||||||
|
'en_revision',
|
||||||
|
'revisada',
|
||||||
|
'cotizada',
|
||||||
|
'por_autorizar',
|
||||||
|
'autorizada',
|
||||||
|
'autorizacion_parcial',
|
||||||
|
'en_reparacion',
|
||||||
|
'reparada',
|
||||||
|
'por_entregar',
|
||||||
|
'entregado',
|
||||||
|
'por_enviar',
|
||||||
|
'enviado',
|
||||||
|
'por_facturar',
|
||||||
|
'facturada',
|
||||||
|
'por_recolectar',
|
||||||
|
'cancelada',
|
||||||
|
]
|
||||||
|
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
|
||||||
|
|
||||||
|
# Allow moving from any non-terminal status to any other non-terminal status,
|
||||||
|
# plus cancellation. Terminal statuses cannot change.
|
||||||
VALID_TRANSITIONS = {
|
VALID_TRANSITIONS = {
|
||||||
'received': ['diagnosis', 'cancelled'],
|
status: [s for s in ORDER_STATUSES if s != status]
|
||||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
for status in ORDER_STATUSES
|
||||||
'waiting_parts': ['repair', 'cancelled'],
|
|
||||||
'repair': ['ready', 'cancelled'],
|
|
||||||
'ready': ['delivered', 'cancelled'],
|
|
||||||
'delivered': [],
|
|
||||||
'cancelled': [],
|
|
||||||
}
|
}
|
||||||
|
for terminal in TERMINAL_STATUSES:
|
||||||
|
VALID_TRANSITIONS[terminal] = []
|
||||||
|
|
||||||
|
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
|
||||||
|
_LEGACY_STATUSES = {
|
||||||
|
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
|
||||||
|
}
|
||||||
|
for legacy in _LEGACY_STATUSES:
|
||||||
|
VALID_TRANSITIONS.setdefault(legacy, [])
|
||||||
|
|
||||||
|
|
||||||
def _generate_order_number(conn):
|
def _generate_order_number(conn):
|
||||||
@@ -45,6 +73,18 @@ def _generate_order_number(conn):
|
|||||||
return f"{prefix}{new_num}"
|
return f"{prefix}{new_num}"
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_delivery(data):
|
||||||
|
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
|
||||||
|
dm = data.get('delivery_method')
|
||||||
|
if dm not in _VALID_DELIVERY_METHODS:
|
||||||
|
data['delivery_method'] = None
|
||||||
|
if data.get('delivery_method') not in ('delivery', 'courier'):
|
||||||
|
data['courier_id'] = None
|
||||||
|
|
||||||
|
|
||||||
def create_service_order(conn, data):
|
def create_service_order(conn, data):
|
||||||
"""Create a new service order.
|
"""Create a new service order.
|
||||||
|
|
||||||
@@ -52,9 +92,10 @@ def create_service_order(conn, data):
|
|||||||
customer_id, vehicle_id, branch_id, priority,
|
customer_id, vehicle_id, branch_id, priority,
|
||||||
reception_notes, estimated_cost, estimated_completion,
|
reception_notes, estimated_cost, estimated_completion,
|
||||||
employee_id, mileage_in, fuel_level, created_by,
|
employee_id, mileage_in, fuel_level, created_by,
|
||||||
delivery_method, courier_id, is_direct
|
delivery_method, courier_id, is_direct, requires_invoice
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
_normalize_delivery(data)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
order_number = _generate_order_number(conn)
|
order_number = _generate_order_number(conn)
|
||||||
|
|
||||||
@@ -62,19 +103,23 @@ def create_service_order(conn, data):
|
|||||||
INSERT INTO service_orders
|
INSERT INTO service_orders
|
||||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||||
priority, reception_notes, estimated_cost, estimated_completion,
|
priority, reception_notes, estimated_cost, estimated_completion,
|
||||||
employee_id, mileage_in, fuel_level, created_by,
|
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
|
||||||
delivery_method, courier_id, is_direct)
|
delivery_method, courier_id, is_direct, requires_invoice,
|
||||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s,
|
workshop_name, customer_address, customer_phone, vehicle_description)
|
||||||
%s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||||
data.get('vehicle_id'), order_number,
|
data.get('vehicle_id'), order_number,
|
||||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||||
data.get('employee_id'), data.get('mileage_in'),
|
data.get('employee_id'), data.get('mechanic_name'),
|
||||||
data.get('fuel_level'), data.get('created_by'),
|
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
|
||||||
data.get('delivery_method'), data.get('courier_id'), data.get('is_direct', False),
|
data.get('delivery_method'), data.get('courier_id'),
|
||||||
|
data.get('is_direct', False), data.get('requires_invoice', False),
|
||||||
|
data.get('workshop_name'), data.get('customer_address'),
|
||||||
|
data.get('customer_phone'), data.get('vehicle_description'),
|
||||||
))
|
))
|
||||||
so_id = cur.fetchone()[0]
|
so_id = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -82,7 +127,7 @@ def create_service_order(conn, data):
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO service_order_status_history
|
INSERT INTO service_order_status_history
|
||||||
(service_order_id, new_status, changed_by, notes)
|
(service_order_id, new_status, changed_by, notes)
|
||||||
VALUES (%s, 'received', %s, 'Orden creada')
|
VALUES (%s, 'por_revisar', %s, 'Orden creada')
|
||||||
""", (so_id, data.get('created_by')))
|
""", (so_id, data.get('created_by')))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -95,7 +140,7 @@ def get_service_order(conn, so_id):
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT so.id, so.order_number, so.status, so.priority,
|
SELECT so.id, so.order_number, so.status, so.priority,
|
||||||
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
||||||
c.address as customer_address,
|
c.address as customer_address, c.price_tier as customer_price_tier,
|
||||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||||
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
|
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
|
||||||
so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||||
@@ -103,10 +148,12 @@ def get_service_order(conn, so_id):
|
|||||||
so.estimated_completion, so.actual_completion, so.delivered_at,
|
so.estimated_completion, so.actual_completion, so.delivered_at,
|
||||||
so.mileage_in, so.mileage_out, so.fuel_level,
|
so.mileage_in, so.mileage_out, so.fuel_level,
|
||||||
so.employee_id, e.name as employee_name,
|
so.employee_id, e.name as employee_name,
|
||||||
|
so.mechanic_name,
|
||||||
so.created_by, creator.name as created_by_name,
|
so.created_by, creator.name as created_by_name,
|
||||||
so.created_at, so.updated_at,
|
so.created_at, so.updated_at,
|
||||||
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
|
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
|
||||||
so.sale_id
|
so.requires_invoice, so.sale_id,
|
||||||
|
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
|
||||||
FROM service_orders so
|
FROM service_orders so
|
||||||
LEFT JOIN customers c ON so.customer_id = c.id
|
LEFT JOIN customers c ON so.customer_id = c.id
|
||||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||||
@@ -124,28 +171,33 @@ def get_service_order(conn, so_id):
|
|||||||
so = {
|
so = {
|
||||||
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
||||||
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
||||||
'customer_address': row[7],
|
'customer_address': row[7], 'customer_price_tier': row[8],
|
||||||
'vehicle_id': row[8], 'vehicle_plate': row[9], 'vehicle_make': row[10], 'vehicle_model': row[11],
|
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
|
||||||
'branch_id': row[12], 'branch_name': row[13], 'branch_address': row[14], 'branch_phone': row[15],
|
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
|
||||||
'reception_notes': row[16], 'diagnosis_notes': row[17],
|
'reception_notes': row[17], 'diagnosis_notes': row[18],
|
||||||
'repair_notes': row[18], 'delivery_notes': row[19],
|
'repair_notes': row[19], 'delivery_notes': row[20],
|
||||||
'estimated_cost': float(row[20]) if row[20] else None,
|
'estimated_cost': float(row[21]) if row[21] else None,
|
||||||
'final_cost': float(row[21]) if row[21] else None,
|
'final_cost': float(row[22]) if row[22] else None,
|
||||||
'estimated_completion': str(row[22]) if row[22] else None,
|
'estimated_completion': str(row[23]) if row[23] else None,
|
||||||
'actual_completion': str(row[23]) if row[23] else None,
|
'actual_completion': str(row[24]) if row[24] else None,
|
||||||
'delivered_at': str(row[24]) if row[24] else None,
|
'delivered_at': str(row[25]) if row[25] else None,
|
||||||
'mileage_in': row[25], 'mileage_out': row[26], 'fuel_level': row[27],
|
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
|
||||||
'employee_id': row[28], 'employee_name': row[29],
|
'employee_id': row[29], 'employee_name': row[30],
|
||||||
'created_by': row[30], 'created_by_name': row[31],
|
'mechanic_name': row[31],
|
||||||
'created_at': str(row[32]), 'updated_at': str(row[33]),
|
'created_by': row[32], 'created_by_name': row[33],
|
||||||
'delivery_method': row[34], 'courier_id': row[35], 'courier_name': row[36],
|
'created_at': str(row[34]), 'updated_at': str(row[35]),
|
||||||
'is_direct': bool(row[37]) if row[37] is not None else False,
|
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
|
||||||
'sale_id': row[38],
|
'is_direct': bool(row[39]) if row[39] is not None else False,
|
||||||
|
'requires_invoice': bool(row[40]) if row[40] is not None else False,
|
||||||
|
'sale_id': row[41],
|
||||||
|
'workshop_name': row[42], 'customer_address': row[43],
|
||||||
|
'customer_phone': row[44], 'vehicle_description': row[45],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Items
|
# Items
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
|
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||||
|
mechanic_id, observations
|
||||||
FROM service_order_items
|
FROM service_order_items
|
||||||
WHERE service_order_id = %s
|
WHERE service_order_id = %s
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -161,6 +213,7 @@ def get_service_order(conn, so_id):
|
|||||||
'unit_cost': float(r[5]) if r[5] else None,
|
'unit_cost': float(r[5]) if r[5] else None,
|
||||||
'unit_price': price,
|
'unit_price': price,
|
||||||
'status': r[7], 'notes': r[8],
|
'status': r[7], 'notes': r[8],
|
||||||
|
'mechanic_id': r[9], 'observations': r[10],
|
||||||
})
|
})
|
||||||
total_parts += qty * price
|
total_parts += qty * price
|
||||||
|
|
||||||
@@ -238,8 +291,11 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
where_clauses.append("so.is_direct = %s")
|
where_clauses.append("so.is_direct = %s")
|
||||||
params.append(is_direct)
|
params.append(is_direct)
|
||||||
if q:
|
if q:
|
||||||
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
|
where_clauses.append(
|
||||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
|
||||||
|
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
|
||||||
|
)
|
||||||
|
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||||
|
|
||||||
where_clauses.append("so.is_deleted = false")
|
where_clauses.append("so.is_deleted = false")
|
||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
@@ -259,14 +315,18 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
so.branch_id, b.name as branch_name,
|
so.branch_id, b.name as branch_name,
|
||||||
so.estimated_cost, so.final_cost,
|
so.estimated_cost, so.final_cost,
|
||||||
so.delivery_method, co.name as courier_name, so.is_direct,
|
so.delivery_method, co.name as courier_name, so.is_direct,
|
||||||
so.sale_id, so.created_at,
|
so.requires_invoice, so.sale_id, so.created_at,
|
||||||
creator.name as created_by_name
|
creator.name as created_by_name,
|
||||||
|
so.employee_id, mech.name as employee_name,
|
||||||
|
so.mechanic_name,
|
||||||
|
so.workshop_name, so.vehicle_description
|
||||||
FROM service_orders so
|
FROM service_orders so
|
||||||
LEFT JOIN customers c ON so.customer_id = c.id
|
LEFT JOIN customers c ON so.customer_id = c.id
|
||||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||||
LEFT JOIN branches b ON so.branch_id = b.id
|
LEFT JOIN branches b ON so.branch_id = b.id
|
||||||
LEFT JOIN couriers co ON so.courier_id = co.id
|
LEFT JOIN couriers co ON so.courier_id = co.id
|
||||||
LEFT JOIN employees creator ON so.created_by = creator.id
|
LEFT JOIN employees creator ON so.created_by = creator.id
|
||||||
|
LEFT JOIN employees mech ON so.employee_id = mech.id
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE so.priority
|
CASE so.priority
|
||||||
@@ -292,8 +352,12 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
'final_cost': final,
|
'final_cost': final,
|
||||||
'delivery_method': r[14], 'courier_name': r[15],
|
'delivery_method': r[14], 'courier_name': r[15],
|
||||||
'is_direct': bool(r[16]) if r[16] is not None else False,
|
'is_direct': bool(r[16]) if r[16] is not None else False,
|
||||||
'sale_id': r[17], 'created_at': str(r[18]),
|
'requires_invoice': bool(r[17]) if r[17] is not None else False,
|
||||||
'created_by_name': r[19],
|
'sale_id': r[18], 'created_at': str(r[19]),
|
||||||
|
'created_by_name': r[20],
|
||||||
|
'employee_id': r[21], 'employee_name': r[22],
|
||||||
|
'mechanic_name': r[23],
|
||||||
|
'workshop_name': r[24], 'vehicle_description': r[25],
|
||||||
'total': round(final or estimated, 2),
|
'total': round(final or estimated, 2),
|
||||||
'paid': 0.0, # to be computed if needed
|
'paid': 0.0, # to be computed if needed
|
||||||
})
|
})
|
||||||
@@ -323,9 +387,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
|||||||
# Update status
|
# Update status
|
||||||
extra_sets = []
|
extra_sets = []
|
||||||
extra_vals = []
|
extra_vals = []
|
||||||
if new_status == 'ready':
|
if new_status == 'reparada':
|
||||||
extra_sets.append("actual_completion = NOW()")
|
extra_sets.append("actual_completion = NOW()")
|
||||||
if new_status == 'delivered':
|
if new_status == 'entregado':
|
||||||
extra_sets.append("delivered_at = NOW()")
|
extra_sets.append("delivered_at = NOW()")
|
||||||
extra_sets.append("delivered_by = %s")
|
extra_sets.append("delivered_by = %s")
|
||||||
extra_vals.append(changed_by)
|
extra_vals.append(changed_by)
|
||||||
@@ -354,14 +418,16 @@ def add_item(conn, so_id, item_data):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO service_order_items
|
INSERT INTO service_order_items
|
||||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
|
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
mechanic_id, observations)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||||
item_data.get('name'), item_data.get('quantity', 1),
|
item_data.get('name'), item_data.get('quantity', 1),
|
||||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||||
|
item_data.get('mechanic_id'), item_data.get('observations'),
|
||||||
))
|
))
|
||||||
item_id = cur.fetchone()[0]
|
item_id = cur.fetchone()[0]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -371,7 +437,7 @@ def add_item(conn, so_id, item_data):
|
|||||||
|
|
||||||
def update_item(conn, item_id, data):
|
def update_item(conn, item_id, data):
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
|
||||||
sets = []
|
sets = []
|
||||||
vals = []
|
vals = []
|
||||||
for field in allowed:
|
for field in allowed:
|
||||||
@@ -453,12 +519,14 @@ def remove_labor(conn, labor_id):
|
|||||||
|
|
||||||
def update_service_order(conn, so_id, data):
|
def update_service_order(conn, so_id, data):
|
||||||
"""Update general service order fields."""
|
"""Update general service order fields."""
|
||||||
|
_normalize_delivery(data)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
||||||
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||||
'employee_id', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||||
'delivery_method', 'courier_id', 'is_direct']
|
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
|
||||||
|
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
|
||||||
sets = []
|
sets = []
|
||||||
vals = []
|
vals = []
|
||||||
for field in allowed:
|
for field in allowed:
|
||||||
@@ -491,15 +559,15 @@ def get_kanban_summary(conn, branch_id=None):
|
|||||||
GROUP BY status
|
GROUP BY status
|
||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
summary[r[0]] = r[1]
|
summary[r[0]] = r[1]
|
||||||
|
|
||||||
# Overdue orders (estimated_completion passed and not ready/delivered)
|
# Overdue orders (estimated_completion passed and not delivered/invoiced)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT count(*) FROM service_orders
|
SELECT count(*) FROM service_orders
|
||||||
WHERE estimated_completion < NOW()
|
WHERE estimated_completion < NOW()
|
||||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
AND status NOT IN ('entregado', 'facturada', 'cancelada')
|
||||||
AND is_deleted = false
|
AND is_deleted = false
|
||||||
{branch_filter}
|
{branch_filter}
|
||||||
""", params)
|
""", params)
|
||||||
@@ -812,6 +880,152 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
|||||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_remission(conn, so_id, sale_data, employee_id=None):
|
||||||
|
"""Convert a service order into a counter remission note (pending payment).
|
||||||
|
|
||||||
|
sale_data keys:
|
||||||
|
register_id: int (optional)
|
||||||
|
notes: str (optional)
|
||||||
|
|
||||||
|
Returns dict with sale_id, total, items_count.
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
so = get_service_order(conn, so_id)
|
||||||
|
if not so:
|
||||||
|
cur.close()
|
||||||
|
raise ValueError("Service order not found")
|
||||||
|
if so["status"] == "cancelled":
|
||||||
|
cur.close()
|
||||||
|
raise ValueError("Cannot convert a cancelled service order")
|
||||||
|
if so.get("sale_id"):
|
||||||
|
cur.close()
|
||||||
|
raise ValueError("Service order already converted")
|
||||||
|
|
||||||
|
branch_id = so["branch_id"]
|
||||||
|
customer_id = so["customer_id"]
|
||||||
|
|
||||||
|
# Build sale items from SO parts and labor
|
||||||
|
sale_items = []
|
||||||
|
for item in so.get("items", []):
|
||||||
|
if item.get("status") == "cancelled":
|
||||||
|
continue
|
||||||
|
qty = int(item.get("quantity", 1))
|
||||||
|
unit_price = float(item.get("unit_price") or 0)
|
||||||
|
unit_cost = float(item.get("unit_cost") or 0)
|
||||||
|
sale_items.append({
|
||||||
|
"inventory_id": item.get("inventory_id"),
|
||||||
|
"part_number": item.get("part_number") or "PART",
|
||||||
|
"name": item.get("name") or "Refaccion",
|
||||||
|
"quantity": qty,
|
||||||
|
"unit_price": unit_price,
|
||||||
|
"unit_cost": unit_cost,
|
||||||
|
"tax_rate": 0.16,
|
||||||
|
})
|
||||||
|
|
||||||
|
for labor in so.get("labor", []):
|
||||||
|
if labor.get("status") == "cancelled":
|
||||||
|
continue
|
||||||
|
sale_items.append({
|
||||||
|
"inventory_id": None,
|
||||||
|
"part_number": "SERV",
|
||||||
|
"name": labor.get("description") or "Mano de obra",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": float(labor.get("total_cost") or 0),
|
||||||
|
"unit_cost": 0,
|
||||||
|
"tax_rate": 0.16,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not sale_items:
|
||||||
|
cur.close()
|
||||||
|
raise ValueError("No items or labor to invoice")
|
||||||
|
|
||||||
|
subtotal = 0.0
|
||||||
|
tax_total = 0.0
|
||||||
|
for item in sale_items:
|
||||||
|
item_subtotal = item["quantity"] * item["unit_price"]
|
||||||
|
item_tax = item_subtotal * item["tax_rate"]
|
||||||
|
item["subtotal"] = item_subtotal
|
||||||
|
item["tax_amount"] = item_tax
|
||||||
|
subtotal += item_subtotal
|
||||||
|
tax_total += item_tax
|
||||||
|
|
||||||
|
total = subtotal + tax_total
|
||||||
|
register_id = sale_data.get("register_id")
|
||||||
|
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sales
|
||||||
|
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||||
|
status, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
branch_id,
|
||||||
|
customer_id,
|
||||||
|
employee_id,
|
||||||
|
register_id,
|
||||||
|
subtotal,
|
||||||
|
0,
|
||||||
|
tax_total,
|
||||||
|
total,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
notes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sale_id, _created_at = cur.fetchone()
|
||||||
|
|
||||||
|
for item in sale_items:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sale_items
|
||||||
|
(sale_id, inventory_id, part_number, name, quantity,
|
||||||
|
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sale_id,
|
||||||
|
item["inventory_id"],
|
||||||
|
item["part_number"],
|
||||||
|
item["name"],
|
||||||
|
item["quantity"],
|
||||||
|
item["unit_price"],
|
||||||
|
item["unit_cost"],
|
||||||
|
item["tax_rate"],
|
||||||
|
item["tax_amount"],
|
||||||
|
item["subtotal"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reserve inventory for parts
|
||||||
|
for item in so.get("items", []):
|
||||||
|
if item.get("status") == "cancelled":
|
||||||
|
continue
|
||||||
|
inventory_id = item.get("inventory_id")
|
||||||
|
qty = int(item.get("quantity", 0))
|
||||||
|
if inventory_id and qty > 0:
|
||||||
|
inventory_engine.record_reservation(
|
||||||
|
conn,
|
||||||
|
inventory_id,
|
||||||
|
branch_id,
|
||||||
|
qty,
|
||||||
|
sale_id=sale_id,
|
||||||
|
cost_at_time=float(item.get("unit_cost") or 0),
|
||||||
|
notes=f"Reserva nota de remision orden {so['order_number']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link order to sale (the remission note)
|
||||||
|
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||||
|
|
||||||
|
|
||||||
def assign_mechanic(conn, so_id, employee_id):
|
def assign_mechanic(conn, so_id, employee_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|||||||
@@ -713,18 +713,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pago-tabs {
|
.pago-tabs {
|
||||||
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.pago-tab {
|
.pago-tab {
|
||||||
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
|
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
|
||||||
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
|
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
||||||
background: transparent; border: none; color: var(--color-text-muted);
|
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||||
cursor: pointer; border-bottom: 2px solid transparent;
|
border-radius: var(--radius-md); color: var(--color-text-muted);
|
||||||
margin-bottom: -2px; transition: var(--transition-fast);
|
cursor: pointer; transition: var(--transition-fast);
|
||||||
display: flex; align-items: center; gap: var(--space-2);
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
text-align: center; min-height: 44px;
|
||||||
}
|
}
|
||||||
.pago-tab:hover { color: var(--color-text-primary); }
|
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
||||||
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
|
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||||
|
|
||||||
.tab-content { padding: var(--space-6); display: none; }
|
.tab-content { padding: var(--space-6); display: none; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
|
|||||||
195
pos/static/css/remission_notes.css
Normal file
195
pos/static/css/remission_notes.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/* Remission notes page — matches the Nexus POS design system */
|
||||||
|
|
||||||
|
/* Scrollable page content area */
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
.page-content::-webkit-scrollbar { width: 6px; }
|
||||||
|
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||||
|
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
|
||||||
|
|
||||||
|
/* Filters card */
|
||||||
|
.filters-card {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
[data-theme="modern"] .filters-card {
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date input reuse select-filter styling */
|
||||||
|
.select-filter[type="date"] {
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges for remission notes */
|
||||||
|
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||||
|
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
|
||||||
|
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* Action buttons in table */
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.action-btn svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.action-btn--ghost {
|
||||||
|
background: var(--btn-ghost-bg);
|
||||||
|
color: var(--btn-ghost-text);
|
||||||
|
border-color: var(--btn-ghost-border);
|
||||||
|
}
|
||||||
|
.action-btn--ghost:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.action-btn--primary {
|
||||||
|
background: var(--btn-primary-bg);
|
||||||
|
color: var(--btn-primary-text);
|
||||||
|
border-color: var(--btn-primary-border);
|
||||||
|
}
|
||||||
|
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal, 1050);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.modal-overlay.is-open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
[data-theme="modern"] .modal {
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.modal__title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--text-h6);
|
||||||
|
font-weight: var(--heading-weight-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.modal__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
.modal__close:hover { color: var(--color-text-primary); }
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ticket preview (monospace) */
|
||||||
|
.ticket-preview {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.ticket-preview__center { text-align: center; }
|
||||||
|
.ticket-preview__bold { font-weight: 700; }
|
||||||
|
.ticket-preview__line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.ticket-preview__divider {
|
||||||
|
border-top: 1px dashed var(--color-border-strong);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
.ticket-preview__items { margin: var(--space-3) 0; }
|
||||||
|
.ticket-preview__item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.ticket-preview__footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.page-content {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.search-box, .select-filter {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.toolbar__spacer { display: none; }
|
||||||
|
}
|
||||||
@@ -947,6 +947,30 @@ body {
|
|||||||
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||||
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||||
|
.badge--por_revisar { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||||
|
.badge--en_revision { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||||
|
.badge--revisada { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||||
|
.badge--cotizada { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||||
|
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
|
||||||
|
.badge--autorizada { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||||
|
.badge--autorizacion_parcial { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
|
.badge--en_reparacion { background: rgba(245, 166, 35, 0.18); color: #d97706; }
|
||||||
|
.badge--reparada { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
|
||||||
|
.badge--por_entregar { background: rgba(59, 130, 246, 0.16); color: #2563eb; }
|
||||||
|
.badge--entregado { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
|
.badge--por_enviar { background: rgba(99, 102, 241, 0.16); color: #4f46e5; }
|
||||||
|
.badge--enviado { background: rgba(14, 165, 233, 0.12); color: #0ea5e9; }
|
||||||
|
.badge--por_facturar { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
|
||||||
|
.badge--facturada { background: rgba(236, 72, 153, 0.12); color: #ec4899; }
|
||||||
|
.badge--por_recolectar { background: rgba(100, 116, 139, 0.12); color: #64748b; }
|
||||||
|
.badge--cancelada { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||||
|
.badge--revisando { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||||
|
.badge--revisado { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||||
|
.badge--cotizado { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||||
|
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
|
||||||
|
.badge--autorizado { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||||
|
.badge--reparado { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
|
||||||
|
.badge--cancelado { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||||
|
|
||||||
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||||
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||||
|
|||||||
@@ -182,10 +182,85 @@
|
|||||||
permissions: payload.permissions || []
|
permissions: payload.permissions || []
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Restrict workshop/mechanic users to the workshop view only ───
|
// ─── Page guard based on role + permissions ───
|
||||||
if ((role === 'workshop' || role === 'mechanic') && window.location.pathname !== '/pos/workshop') {
|
function isPageAllowed(pagePath, userRole, userPerms) {
|
||||||
window.location.replace('/pos/workshop');
|
if (userRole === 'owner' || userRole === 'admin') return true;
|
||||||
return;
|
|
||||||
|
// Workshop/mechanic accounts always see Taller; extra modules depend on permissions.
|
||||||
|
if (userRole === 'workshop' || userRole === 'mechanic') {
|
||||||
|
var allowed = ['/pos/workshop'];
|
||||||
|
var permMap = {
|
||||||
|
'customers.view': '/pos/customers',
|
||||||
|
'inventory.view': '/pos/inventory',
|
||||||
|
'catalog.view': '/pos/catalog',
|
||||||
|
'pos.sell': '/pos/sale',
|
||||||
|
'pos.view': '/pos/sale',
|
||||||
|
'pos.remission': '/pos/remission-notes',
|
||||||
|
'invoicing.view': '/pos/invoicing',
|
||||||
|
'quotations.view': '/pos/quotations',
|
||||||
|
'accounting.view': '/pos/accounting',
|
||||||
|
'reports.view': '/pos/reports',
|
||||||
|
'config.view': '/pos/config',
|
||||||
|
'config.edit': '/pos/config'
|
||||||
|
};
|
||||||
|
for (var p in permMap) {
|
||||||
|
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||||
|
allowed.push(permMap[p]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed.indexOf(pagePath) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter: fixed module set (no dashboard).
|
||||||
|
if (userRole === 'counter') {
|
||||||
|
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/reports'].indexOf(pagePath) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cashier: fixed module set (no dashboard).
|
||||||
|
if (userRole === 'cashier') {
|
||||||
|
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/invoicing','/pos/reports'].indexOf(pagePath) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforcePageGuard(userRole, userPerms) {
|
||||||
|
if (isPageAllowed(path, userRole, userPerms)) return true;
|
||||||
|
var fallback = '/pos/catalog';
|
||||||
|
if (userRole === 'counter' || userRole === 'cashier') fallback = '/pos/sale';
|
||||||
|
else if (userRole === 'workshop' || userRole === 'mechanic') fallback = '/pos/workshop';
|
||||||
|
window.location.replace(fallback);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Refresh permissions/token from server before enforcing the guard ───
|
||||||
|
// This makes permission changes effective without requiring a full re-login.
|
||||||
|
try {
|
||||||
|
fetch('/pos/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) return r.json();
|
||||||
|
return null;
|
||||||
|
}).then(function(data) {
|
||||||
|
if (data && data.token) {
|
||||||
|
localStorage.setItem('pos_token', data.token);
|
||||||
|
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
|
||||||
|
token = data.token;
|
||||||
|
window.POS_USER.token = data.token;
|
||||||
|
window.POS_USER.permissions = data.permissions || [];
|
||||||
|
window.POS_USER.branchId = data.employee.branch_id;
|
||||||
|
}
|
||||||
|
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
|
||||||
|
if (typeof window.renderSidebar === 'function') {
|
||||||
|
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
enforcePageGuard(role, payload.permissions || []);
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
enforcePageGuard(role, payload.permissions || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Preload enabled modules for sidebar filtering ───
|
// ─── Preload enabled modules for sidebar filtering ───
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const Config = (() => {
|
|||||||
|
|
||||||
// Cache for branches (used by employee modal selector)
|
// Cache for branches (used by employee modal selector)
|
||||||
let _branches = [];
|
let _branches = [];
|
||||||
|
let _rolePermissions = {};
|
||||||
|
let _availablePermissions = [];
|
||||||
|
|
||||||
function token() {
|
function token() {
|
||||||
return localStorage.getItem('pos_token') || '';
|
return localStorage.getItem('pos_token') || '';
|
||||||
@@ -108,6 +110,7 @@ const Config = (() => {
|
|||||||
owner: 'Dueno',
|
owner: 'Dueno',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
cashier: 'Cajero',
|
cashier: 'Cajero',
|
||||||
|
counter: 'Mostrador',
|
||||||
warehouse: 'Almacenista',
|
warehouse: 'Almacenista',
|
||||||
accountant: 'Contador',
|
accountant: 'Contador',
|
||||||
workshop: 'Taller',
|
workshop: 'Taller',
|
||||||
@@ -118,6 +121,7 @@ const Config = (() => {
|
|||||||
owner: 'badge--owner',
|
owner: 'badge--owner',
|
||||||
admin: 'badge--blue',
|
admin: 'badge--blue',
|
||||||
cashier: 'badge--green',
|
cashier: 'badge--green',
|
||||||
|
counter: 'badge--gray',
|
||||||
warehouse: 'badge--yellow',
|
warehouse: 'badge--yellow',
|
||||||
accountant: 'badge--purple',
|
accountant: 'badge--purple',
|
||||||
workshop: 'badge--orange',
|
workshop: 'badge--orange',
|
||||||
@@ -961,6 +965,15 @@ const Config = (() => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Config.loadModules:', e);
|
console.error('Config.loadModules:', e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
|
||||||
|
if (!res2.ok) return;
|
||||||
|
var d2 = await res2.json();
|
||||||
|
var cbCr = document.getElementById('cfg-module-counter-remission');
|
||||||
|
if (cbCr) cbCr.checked = d2.enabled === true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Config.loadCounterRemission:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveModules() {
|
async function saveModules() {
|
||||||
@@ -968,7 +981,8 @@ const Config = (() => {
|
|||||||
var cbMp = document.getElementById('cfg-module-marketplace');
|
var cbMp = document.getElementById('cfg-module-marketplace');
|
||||||
var cbMeli = document.getElementById('cfg-module-meli');
|
var cbMeli = document.getElementById('cfg-module-meli');
|
||||||
var cbCat = document.getElementById('cfg-module-catalog');
|
var cbCat = document.getElementById('cfg-module-catalog');
|
||||||
if (!cbWa && !cbMp && !cbMeli && !cbCat) return;
|
var cbCr = document.getElementById('cfg-module-counter-remission');
|
||||||
|
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbCr) return;
|
||||||
try {
|
try {
|
||||||
var data = {
|
var data = {
|
||||||
whatsapp: cbWa ? cbWa.checked : true,
|
whatsapp: cbWa ? cbWa.checked : true,
|
||||||
@@ -986,12 +1000,112 @@ const Config = (() => {
|
|||||||
throw new Error(err.error || 'Save failed');
|
throw new Error(err.error || 'Save failed');
|
||||||
}
|
}
|
||||||
localStorage.setItem('pos_modules', JSON.stringify(data));
|
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||||
|
if (cbCr) {
|
||||||
|
var res2 = await fetch(API + '/counter-remission', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ enabled: cbCr.checked })
|
||||||
|
});
|
||||||
|
if (!res2.ok) {
|
||||||
|
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
|
||||||
|
throw new Error(err2.error || 'Save failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
toast('Módulos actualizados');
|
toast('Módulos actualizados');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tab navigation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
|
||||||
|
var isActive = sec.dataset.tab === tab;
|
||||||
|
sec.classList.toggle('active', isActive);
|
||||||
|
sec.style.display = isActive ? '' : 'none';
|
||||||
|
});
|
||||||
|
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Role permissions editor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async function loadRolePermissions() {
|
||||||
|
try {
|
||||||
|
var res = await fetch(API + '/role-permissions', { headers: headers() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
var data = await res.json();
|
||||||
|
_rolePermissions = data.roles || {};
|
||||||
|
_availablePermissions = data.available || [];
|
||||||
|
renderRolePermissions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Config.loadRolePermissions:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRolePermissions() {
|
||||||
|
var container = document.getElementById('role-permissions-container');
|
||||||
|
var roleSel = document.getElementById('cfg-perm-role');
|
||||||
|
if (!container || !roleSel) return;
|
||||||
|
var role = roleSel.value;
|
||||||
|
if (!role) {
|
||||||
|
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var current = _rolePermissions[role] || [];
|
||||||
|
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
|
||||||
|
_availablePermissions.forEach(function(group) {
|
||||||
|
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
|
||||||
|
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">' + escapeHtml(group.module) + '</h4>';
|
||||||
|
group.permissions.forEach(function(p) {
|
||||||
|
var checked = current.indexOf(p.key) !== -1 ? 'checked' : '';
|
||||||
|
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
|
||||||
|
html += '<input type="checkbox" data-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
|
||||||
|
html += '<span>' + escapeHtml(p.label) + '</span>';
|
||||||
|
html += '</label>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRolePermissions() {
|
||||||
|
var roleSel = document.getElementById('cfg-perm-role');
|
||||||
|
var status = document.getElementById('role-permissions-status');
|
||||||
|
if (!roleSel || !roleSel.value) {
|
||||||
|
if (status) status.textContent = 'Selecciona un rol';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var role = roleSel.value;
|
||||||
|
var selected = [];
|
||||||
|
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
|
||||||
|
if (cb.checked) selected.push(cb.dataset.permKey);
|
||||||
|
});
|
||||||
|
var payload = { roles: {} };
|
||||||
|
payload.roles[role] = selected;
|
||||||
|
try {
|
||||||
|
var res = await fetch(API + '/role-permissions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
var data = await res.json().catch(function() { return { error: res.statusText }; });
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
||||||
|
_rolePermissions[role] = selected;
|
||||||
|
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
|
||||||
|
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = e.message;
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -1044,6 +1158,11 @@ const Config = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show permissions tab only for owner/admin
|
||||||
|
var isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||||
|
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
|
||||||
|
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
|
||||||
|
|
||||||
// Load real data in parallel
|
// Load real data in parallel
|
||||||
loadBranches();
|
loadBranches();
|
||||||
loadEmployees();
|
loadEmployees();
|
||||||
@@ -1053,6 +1172,15 @@ const Config = (() => {
|
|||||||
loadAllowedBrands();
|
loadAllowedBrands();
|
||||||
loadModules();
|
loadModules();
|
||||||
loadReceiptConfig();
|
loadReceiptConfig();
|
||||||
|
if (isAdmin) loadRolePermissions();
|
||||||
|
|
||||||
|
// Activate default or stored tab
|
||||||
|
var defaultTab = 'general';
|
||||||
|
try {
|
||||||
|
var storedTab = localStorage.getItem('pos_config_tab');
|
||||||
|
if (storedTab) defaultTab = storedTab;
|
||||||
|
} catch(e) {}
|
||||||
|
switchTab(defaultTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -1074,7 +1202,8 @@ const Config = (() => {
|
|||||||
loadVehicleCompatSource, saveVehicleCompatSource,
|
loadVehicleCompatSource, saveVehicleCompatSource,
|
||||||
loadModules, saveModules,
|
loadModules, saveModules,
|
||||||
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
|
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
|
||||||
openModal, closeModal, openBranchModal, editBranch
|
openModal, closeModal, openBranchModal, editBranch,
|
||||||
|
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ var I18N = {
|
|||||||
'catalog': 'Catalogo',
|
'catalog': 'Catalogo',
|
||||||
'inventory': 'Inventario',
|
'inventory': 'Inventario',
|
||||||
'diagrams': 'Diagramas',
|
'diagrams': 'Diagramas',
|
||||||
|
'remission_notes': 'Notas de Remisión',
|
||||||
'customers': 'Clientes',
|
'customers': 'Clientes',
|
||||||
'invoicing': 'Facturacion',
|
'invoicing': 'Facturacion',
|
||||||
'accounting': 'Contabilidad',
|
'accounting': 'Contabilidad',
|
||||||
@@ -166,6 +167,7 @@ var I18N = {
|
|||||||
'catalog': 'Catalog',
|
'catalog': 'Catalog',
|
||||||
'inventory': 'Inventory',
|
'inventory': 'Inventory',
|
||||||
'diagrams': 'Diagrams',
|
'diagrams': 'Diagrams',
|
||||||
|
'remission_notes': 'Remission Notes',
|
||||||
'customers': 'Customers',
|
'customers': 'Customers',
|
||||||
'invoicing': 'Invoicing',
|
'invoicing': 'Invoicing',
|
||||||
'accounting': 'Accounting',
|
'accounting': 'Accounting',
|
||||||
|
|||||||
@@ -21,6 +21,19 @@
|
|||||||
var userRole = (user.role || '').toLowerCase();
|
var userRole = (user.role || '').toLowerCase();
|
||||||
var userPerms = user.permissions || [];
|
var userPerms = user.permissions || [];
|
||||||
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
|
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
|
||||||
|
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
|
||||||
|
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
|
||||||
|
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
|
||||||
|
|
||||||
|
// Hide toolbar actions the user is not allowed to use
|
||||||
|
(function applyInventoryPermissions() {
|
||||||
|
var headerNew = document.getElementById('btnHeaderNewProduct');
|
||||||
|
var stockNew = document.getElementById('btnStockNewProduct');
|
||||||
|
var headerImport = document.getElementById('btnHeaderImport');
|
||||||
|
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
|
||||||
|
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
|
||||||
|
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
|
||||||
|
})();
|
||||||
|
|
||||||
// Load compatibility source setting
|
// Load compatibility source setting
|
||||||
(function loadCompatSource() {
|
(function loadCompatSource() {
|
||||||
@@ -186,11 +199,11 @@
|
|||||||
'<td>' + esc(it.location) + '</td>' +
|
'<td>' + esc(it.location) + '</td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
||||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' +
|
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
|
||||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
|
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
|
||||||
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
|
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
|
||||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
||||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
|
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +557,28 @@
|
|||||||
}
|
}
|
||||||
window.submitBulkImport = submitBulkImport;
|
window.submitBulkImport = submitBulkImport;
|
||||||
|
|
||||||
|
function downloadBulkImportTemplate() {
|
||||||
|
var headers = [
|
||||||
|
'numero_de_parte','nombre','marca','precio','cantidad','costo',
|
||||||
|
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
|
||||||
|
];
|
||||||
|
var example = [
|
||||||
|
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
|
||||||
|
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
|
||||||
|
];
|
||||||
|
var csv = [headers.join(','), example.join(',')].join('\n');
|
||||||
|
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'plantilla_inventario.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// PURCHASE / ENTRADA (purchaseModal)
|
// PURCHASE / ENTRADA (purchaseModal)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -83,7 +83,12 @@
|
|||||||
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
||||||
localStorage.setItem('pos_tenant_id', tenantId);
|
localStorage.setItem('pos_tenant_id', tenantId);
|
||||||
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
|
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
|
||||||
window.location.href = '/pos/catalog';
|
var role = (result.data.employee.role || '').toLowerCase();
|
||||||
|
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||||
|
window.location.href = '/pos/workshop';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/pos/catalog';
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
errorEl.textContent = 'Error de conexion';
|
errorEl.textContent = 'Error de conexion';
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ const POS = (() => {
|
|||||||
let canEditPrice = false;
|
let canEditPrice = false;
|
||||||
let canCreateWorkshopOrder = false;
|
let canCreateWorkshopOrder = false;
|
||||||
let canCreateLayaway = false;
|
let canCreateLayaway = false;
|
||||||
|
let canCreateRemission = false;
|
||||||
|
let counterRemissionEnabled = false;
|
||||||
|
let currentPerms = [];
|
||||||
let receiptConfig = {};
|
let receiptConfig = {};
|
||||||
|
let couriers = [];
|
||||||
|
let selectedCourierId = null;
|
||||||
|
|
||||||
// Currency-aware formatter: reads pos_currency from localStorage
|
// Currency-aware formatter: reads pos_currency from localStorage
|
||||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||||
@@ -38,6 +43,16 @@ const POS = (() => {
|
|||||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
// Price based on customer tier: base price (price_1) with tier discount.
|
||||||
|
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||||
|
function priceForTier(basePrice, tier) {
|
||||||
|
const p = parseFloat(basePrice) || 0;
|
||||||
|
const t = parseInt(tier, 10) || 1;
|
||||||
|
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||||
|
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
}
|
}
|
||||||
@@ -66,6 +81,10 @@ const POS = (() => {
|
|||||||
const el = document.querySelector(selector);
|
const el = document.querySelector(selector);
|
||||||
if (el) el.style.display = 'none';
|
if (el) el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
function show(selector) {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) el.style.display = '';
|
||||||
|
}
|
||||||
if (!canCancel) {
|
if (!canCancel) {
|
||||||
hide('#btnCancelSale');
|
hide('#btnCancelSale');
|
||||||
hide('#fkeyEsc');
|
hide('#fkeyEsc');
|
||||||
@@ -77,6 +96,25 @@ const POS = (() => {
|
|||||||
hide('[title="Orden de servicio"]');
|
hide('[title="Orden de servicio"]');
|
||||||
}
|
}
|
||||||
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
|
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
|
||||||
|
|
||||||
|
// Counter remission workflow
|
||||||
|
if (canCreateRemission) {
|
||||||
|
hide('#btnCobrar');
|
||||||
|
hide('.fkey[onclick="POS.checkout()"]');
|
||||||
|
|
||||||
|
hide('[onclick="POS.createLayaway()"]');
|
||||||
|
hide('[onclick="POS.saveQuotation()"]');
|
||||||
|
show('#btnRemission');
|
||||||
|
show('#courierSelectField');
|
||||||
|
} else {
|
||||||
|
hide('#btnRemission');
|
||||||
|
hide('#courierSelectField');
|
||||||
|
}
|
||||||
|
if (!currentPerms.includes('pos.sell')) {
|
||||||
|
hide('#btnPayRemission');
|
||||||
|
} else {
|
||||||
|
show('#btnPayRemission');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Init ────────────────────────────
|
// ─── Init ────────────────────────────
|
||||||
@@ -87,14 +125,26 @@ const POS = (() => {
|
|||||||
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||||
document.getElementById('branchName').textContent = payload.branch_name || '';
|
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||||
const perms = payload.permissions || [];
|
const perms = payload.permissions || [];
|
||||||
|
currentPerms = perms;
|
||||||
|
const employeeRole = payload.role || '';
|
||||||
canViewCost = perms.includes('pos.view_cost');
|
canViewCost = perms.includes('pos.view_cost');
|
||||||
canCancel = perms.includes('pos.cancel');
|
canCancel = perms.includes('pos.cancel');
|
||||||
canDiscount = perms.includes('pos.discount');
|
canDiscount = perms.includes('pos.discount');
|
||||||
canEditPrice = perms.includes('config.edit_prices');
|
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
|
||||||
canCreateWorkshopOrder = perms.includes('workshop.edit');
|
canCreateWorkshopOrder = perms.includes('workshop.edit');
|
||||||
canCreateLayaway = perms.includes('pos.sell');
|
canCreateLayaway = perms.includes('pos.sell');
|
||||||
employeeMaxDiscount = payload.max_discount_pct || 100;
|
employeeMaxDiscount = payload.max_discount_pct || 100;
|
||||||
|
|
||||||
|
// Counter remission feature
|
||||||
|
try {
|
||||||
|
const crCfg = await api('/pos/api/config/counter-remission');
|
||||||
|
counterRemissionEnabled = crCfg.enabled === true;
|
||||||
|
} catch (e) {
|
||||||
|
counterRemissionEnabled = false;
|
||||||
|
}
|
||||||
|
// Counter remission workflow applies only to the counter role.
|
||||||
|
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
|
||||||
|
|
||||||
// Show cost/margin columns and toggle button if permission
|
// Show cost/margin columns and toggle button if permission
|
||||||
if (canViewCost) {
|
if (canViewCost) {
|
||||||
document.getElementById('thCost').style.display = '';
|
document.getElementById('thCost').style.display = '';
|
||||||
@@ -138,6 +188,7 @@ const POS = (() => {
|
|||||||
// Load current register and receipt config
|
// Load current register and receipt config
|
||||||
await loadRegister();
|
await loadRegister();
|
||||||
await loadReceiptConfig();
|
await loadReceiptConfig();
|
||||||
|
await loadCouriers();
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
setupKeyboard();
|
setupKeyboard();
|
||||||
@@ -157,8 +208,10 @@ const POS = (() => {
|
|||||||
currentRegister = null;
|
currentRegister = null;
|
||||||
document.getElementById('registerInfo').innerHTML =
|
document.getElementById('registerInfo').innerHTML =
|
||||||
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
||||||
// Force open register modal on first load
|
// Force open register modal on first load only for users who can sell
|
||||||
showOpenRegisterModal();
|
if (currentPerms.includes('pos.sell')) {
|
||||||
|
showOpenRegisterModal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Register check failed:', e);
|
console.warn('Register check failed:', e);
|
||||||
@@ -174,6 +227,24 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCouriers() {
|
||||||
|
try {
|
||||||
|
const data = await api('/pos/api/logistics/couriers');
|
||||||
|
couriers = data.couriers || [];
|
||||||
|
const sel = document.getElementById('remissionCourier');
|
||||||
|
if (sel) {
|
||||||
|
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
|
||||||
|
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not load couriers:', e);
|
||||||
|
couriers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function rc(key, fallback) {
|
function rc(key, fallback) {
|
||||||
const v = receiptConfig[key];
|
const v = receiptConfig[key];
|
||||||
return v !== undefined && v !== null && v !== '' ? v : fallback;
|
return v !== undefined && v !== null && v !== '' ? v : fallback;
|
||||||
@@ -570,26 +641,11 @@ const POS = (() => {
|
|||||||
if (data.data.length === 0) {
|
if (data.data.length === 0) {
|
||||||
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
||||||
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
||||||
} else if (data.data.length === 1 && q.length >= 8) {
|
|
||||||
// Auto-select single result on barcode scan (long codes)
|
|
||||||
const item = data.data[0];
|
|
||||||
let price = item.price_1;
|
|
||||||
if (currentCustomer) {
|
|
||||||
const tier = currentCustomer.price_tier || 1;
|
|
||||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
|
||||||
}
|
|
||||||
addFromSearch(item, price);
|
|
||||||
input.value = '';
|
|
||||||
hideSearchResults();
|
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
let html = '';
|
let html = '';
|
||||||
data.data.forEach(item => {
|
data.data.forEach(item => {
|
||||||
let price = item.price_1;
|
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||||
if (currentCustomer) {
|
const price = priceForTier(item.price_1, tier);
|
||||||
const tier = currentCustomer.price_tier || 1;
|
|
||||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
|
||||||
}
|
|
||||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||||
@@ -684,7 +740,7 @@ const POS = (() => {
|
|||||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (item.price_1 > 0) {
|
if (item.price_1 > 0) {
|
||||||
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
item.unit_price = priceForTier(item.price_1, tier);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -880,9 +936,12 @@ const POS = (() => {
|
|||||||
transferencia: 'refPayment',
|
transferencia: 'refPayment',
|
||||||
tarjeta: 'refPayment',
|
tarjeta: 'refPayment',
|
||||||
mixto: 'mixedPayment',
|
mixto: 'mixedPayment',
|
||||||
|
credito: 'creditPayment',
|
||||||
|
cheque: 'chequePayment',
|
||||||
|
pendiente: 'pendingPayment',
|
||||||
};
|
};
|
||||||
|
|
||||||
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
|
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const isActive = el.id === tabs[method];
|
const isActive = el.id === tabs[method];
|
||||||
@@ -896,6 +955,12 @@ const POS = (() => {
|
|||||||
const ref = document.getElementById('paymentRef');
|
const ref = document.getElementById('paymentRef');
|
||||||
if (ref) ref.focus();
|
if (ref) ref.focus();
|
||||||
}
|
}
|
||||||
|
if (method === 'cheque') {
|
||||||
|
const chequeAmount = document.getElementById('chequeAmount');
|
||||||
|
if (chequeAmount) chequeAmount.value = fmt(getTotal());
|
||||||
|
const chequeRef = document.getElementById('chequeRef');
|
||||||
|
if (chequeRef) chequeRef.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChange() {
|
function updateChange() {
|
||||||
@@ -933,6 +998,7 @@ const POS = (() => {
|
|||||||
let amountPaid = 0;
|
let amountPaid = 0;
|
||||||
let paymentDetails = [];
|
let paymentDetails = [];
|
||||||
let reference = '';
|
let reference = '';
|
||||||
|
let saleType = 'cash';
|
||||||
|
|
||||||
if (paymentMethod === 'efectivo') {
|
if (paymentMethod === 'efectivo') {
|
||||||
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||||
@@ -952,6 +1018,20 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
||||||
|
} else if (paymentMethod === 'credito') {
|
||||||
|
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
|
||||||
|
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||||
|
if (total > available) {
|
||||||
|
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saleType = 'credit';
|
||||||
|
amountPaid = 0;
|
||||||
|
} else if (paymentMethod === 'cheque') {
|
||||||
|
amountPaid = total;
|
||||||
|
reference = document.getElementById('chequeRef').value.trim();
|
||||||
|
} else if (paymentMethod === 'pendiente') {
|
||||||
|
amountPaid = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saleData = {
|
const saleData = {
|
||||||
@@ -964,7 +1044,7 @@ const POS = (() => {
|
|||||||
})),
|
})),
|
||||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
sale_type: 'cash',
|
sale_type: saleType,
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
amount_paid: amountPaid,
|
amount_paid: amountPaid,
|
||||||
payment_details: paymentDetails,
|
payment_details: paymentDetails,
|
||||||
@@ -983,9 +1063,10 @@ const POS = (() => {
|
|||||||
const convertData = {
|
const convertData = {
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
sale_type: 'cash',
|
sale_type: saleType,
|
||||||
amount_paid: amountPaid,
|
amount_paid: amountPaid,
|
||||||
payment_details: paymentDetails,
|
payment_details: paymentDetails,
|
||||||
|
reference: reference,
|
||||||
};
|
};
|
||||||
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1020,22 +1101,13 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Credit Sale ─────────────────────
|
// ─── Counter Remission Note ────────────
|
||||||
async function creditSale() {
|
async function createRemissionNote() {
|
||||||
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||||
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
|
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
|
||||||
if (!currentRegister) { alert('No hay caja abierta.'); return; }
|
|
||||||
|
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
const noteData = {
|
||||||
|
|
||||||
if (currentCustomer.credit_limit > 0 && total > available) {
|
|
||||||
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saleData = {
|
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
inventory_id: item.inventory_id,
|
inventory_id: item.inventory_id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -1043,19 +1115,21 @@ const POS = (() => {
|
|||||||
discount_pct: item.discount_pct,
|
discount_pct: item.discount_pct,
|
||||||
tax_rate: item.tax_rate,
|
tax_rate: item.tax_rate,
|
||||||
})),
|
})),
|
||||||
customer_id: currentCustomer.id,
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
payment_method: 'credito',
|
notes: 'Nota de remision generada desde mostrador',
|
||||||
sale_type: 'credit',
|
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
amount_paid: 0,
|
courier_id: selectedCourierId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sale = await api('/pos/api/sales', {
|
const sale = await api('/pos/api/sales/remission', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(saleData),
|
body: JSON.stringify(noteData),
|
||||||
});
|
});
|
||||||
|
if (selectedCourierId) {
|
||||||
|
const courier = couriers.find(c => c.id === selectedCourierId);
|
||||||
|
sale.courier_name = courier ? courier.name : '';
|
||||||
|
}
|
||||||
lastSaleId = sale.id;
|
lastSaleId = sale.id;
|
||||||
lastSaleData = sale;
|
lastSaleData = sale;
|
||||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||||
@@ -1063,12 +1137,128 @@ const POS = (() => {
|
|||||||
cart = [];
|
cart = [];
|
||||||
selectedRow = -1;
|
selectedRow = -1;
|
||||||
clearCustomer();
|
clearCustomer();
|
||||||
|
selectedCourierId = null;
|
||||||
|
const sel = document.getElementById('remissionCourier');
|
||||||
|
if (sel) sel.value = '';
|
||||||
renderCart();
|
renderCart();
|
||||||
|
showToast(`Nota de remision NR-${sale.id} generada`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error: ' + e.message);
|
alert('Error al generar nota de remision: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openPayRemissionModal() {
|
||||||
|
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
|
||||||
|
document.getElementById('payRemissionFolio').value = '';
|
||||||
|
document.getElementById('payRemissionDetail').innerHTML = '';
|
||||||
|
document.getElementById('payRemissionResult').innerHTML = '';
|
||||||
|
document.getElementById('payRemissionActions').style.display = 'none';
|
||||||
|
document.getElementById('payRemissionModal').classList.add('open');
|
||||||
|
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePayRemissionModal() {
|
||||||
|
document.getElementById('payRemissionModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingRemissionToPay = null;
|
||||||
|
|
||||||
|
async function searchRemissionToPay() {
|
||||||
|
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
|
||||||
|
if (!folio) { showToast('Ingresa un folio valido'); return; }
|
||||||
|
pendingRemissionToPay = null;
|
||||||
|
try {
|
||||||
|
const sale = await api('/pos/api/sales/' + folio);
|
||||||
|
if (sale.status !== 'pending_payment') {
|
||||||
|
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
|
||||||
|
document.getElementById('payRemissionActions').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingRemissionToPay = sale;
|
||||||
|
let itemsHtml = (sale.items || []).map(it => `
|
||||||
|
<div class="ticket-line">
|
||||||
|
<span class="qty">${it.quantity}</span>
|
||||||
|
<span class="name">${it.name || ''}</span>
|
||||||
|
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
document.getElementById('payRemissionDetail').innerHTML = `
|
||||||
|
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
|
||||||
|
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
|
||||||
|
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
|
||||||
|
<hr class="divider">
|
||||||
|
${itemsHtml}
|
||||||
|
`;
|
||||||
|
document.getElementById('payRemissionActions').style.display = '';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||||||
|
document.getElementById('payRemissionActions').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPayRemission() {
|
||||||
|
if (!pendingRemissionToPay) return;
|
||||||
|
const sale = pendingRemissionToPay;
|
||||||
|
const paymentMethod = document.getElementById('payRemissionMethod').value;
|
||||||
|
let amountPaid = parseFloat(sale.total);
|
||||||
|
let paymentDetails = [];
|
||||||
|
let reference = '';
|
||||||
|
|
||||||
|
if (paymentMethod === 'efectivo') {
|
||||||
|
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
|
||||||
|
if (received < sale.total) { alert('Monto insuficiente'); return; }
|
||||||
|
amountPaid = received;
|
||||||
|
} else if (paymentMethod === 'mixto') {
|
||||||
|
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
|
||||||
|
let sum = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
const method = row.querySelector('select').value;
|
||||||
|
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
|
||||||
|
const ref = row.querySelectorAll('input')[1]?.value || '';
|
||||||
|
if (amount > 0) {
|
||||||
|
paymentDetails.push({ method, amount, reference: ref });
|
||||||
|
sum += amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
|
||||||
|
amountPaid = sum;
|
||||||
|
} else {
|
||||||
|
reference = document.getElementById('payRemissionReference').value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
amount_paid: amountPaid,
|
||||||
|
payment_details: paymentDetails,
|
||||||
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
|
reference: reference,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
closePayRemissionModal();
|
||||||
|
showToast(`Nota NR-${sale.id} pagada`);
|
||||||
|
// Refresh sale object to print paid ticket
|
||||||
|
const updated = await api('/pos/api/sales/' + sale.id);
|
||||||
|
lastSaleId = updated.id;
|
||||||
|
lastSaleData = updated;
|
||||||
|
showTicket(updated);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error al cobrar nota: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePayRemissionMethod() {
|
||||||
|
const method = document.getElementById('payRemissionMethod').value;
|
||||||
|
const cashEl = document.getElementById('payRemissionCash');
|
||||||
|
const refEl = document.getElementById('payRemissionRef');
|
||||||
|
const mixedEl = document.getElementById('payRemissionMixed');
|
||||||
|
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
|
||||||
|
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
||||||
|
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Quotation ───────────────────────
|
// ─── Quotation ───────────────────────
|
||||||
async function saveQuotation() {
|
async function saveQuotation() {
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||||
@@ -1284,8 +1474,9 @@ const POS = (() => {
|
|||||||
hour: '2-digit', minute: '2-digit'
|
hour: '2-digit', minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
||||||
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
|
const isRemission = sale.status === 'pending_payment';
|
||||||
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
|
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
||||||
|
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
|
||||||
|
|
||||||
let itemsHtml = '';
|
let itemsHtml = '';
|
||||||
(sale.items || []).forEach(item => {
|
(sale.items || []).forEach(item => {
|
||||||
@@ -1333,13 +1524,14 @@ const POS = (() => {
|
|||||||
</div>
|
</div>
|
||||||
<hr class="divider-double">
|
<hr class="divider-double">
|
||||||
<div class="folio-line">
|
<div class="folio-line">
|
||||||
<span>VENTA: V-${sale.id}</span>
|
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
|
||||||
<span>${dateStr}</span>
|
<span>${dateStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
||||||
<span>Cliente: ${customerName}</span>
|
<span>Cliente: ${customerName}</span>
|
||||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||||
<span class="qty">Cant</span>
|
<span class="qty">Cant</span>
|
||||||
@@ -1363,6 +1555,12 @@ const POS = (() => {
|
|||||||
${showPayment ? `
|
${showPayment ? `
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<div class="payment-section">
|
<div class="payment-section">
|
||||||
|
${isRemission ? `
|
||||||
|
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
|
||||||
|
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
|
||||||
|
` : `
|
||||||
<div class="ticket-row">
|
<div class="ticket-row">
|
||||||
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1373,11 +1571,13 @@ const POS = (() => {
|
|||||||
<div class="ticket-row" style="font-weight: bold;">
|
<div class="ticket-row" style="font-weight: bold;">
|
||||||
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
`}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<div class="footer-section">
|
<div class="footer-section">
|
||||||
<div class="thanks">${thanksMsg}</div>
|
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
|
||||||
${footerMsg ? `<div>${footerMsg}</div>` : ''}
|
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
|
||||||
|
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1387,7 +1587,7 @@ const POS = (() => {
|
|||||||
const preview = document.getElementById('ticketPreviewContent');
|
const preview = document.getElementById('ticketPreviewContent');
|
||||||
if (preview) preview.innerHTML = ticketHtml;
|
if (preview) preview.innerHTML = ticketHtml;
|
||||||
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
||||||
if (modalHeader) modalHeader.textContent = 'Ticket de Venta';
|
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
|
||||||
|
|
||||||
document.getElementById('ticketModal').classList.add('open');
|
document.getElementById('ticketModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1592,7 +1792,9 @@ const POS = (() => {
|
|||||||
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||||
checkout, confirmPayment, closePaymentModal,
|
checkout, confirmPayment, closePaymentModal,
|
||||||
selectPaymentMethod, updateChange, updateMixedTotal,
|
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||||
creditSale, saveQuotation, createLayaway,
|
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
|
||||||
|
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
|
||||||
|
saveQuotation, createLayaway,
|
||||||
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
||||||
showLastSale, openDrawer,
|
showLastSale, openDrawer,
|
||||||
showTicket, closeTicketModal, printTicket,
|
showTicket, closeTicketModal, printTicket,
|
||||||
|
|||||||
@@ -54,7 +54,20 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track which tabs have been loaded
|
// Track which tabs have been loaded
|
||||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
|
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
|
||||||
|
|
||||||
|
function currentUser() {
|
||||||
|
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
|
||||||
|
}
|
||||||
|
function hasPerm(p) {
|
||||||
|
var u = currentUser();
|
||||||
|
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
|
||||||
|
}
|
||||||
|
function isLimitedUser() {
|
||||||
|
var u = currentUser();
|
||||||
|
var r = (u.role || '').toLowerCase();
|
||||||
|
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
@@ -86,6 +99,7 @@ const Reports = (() => {
|
|||||||
else if (id === 'clientes') loadClientes();
|
else if (id === 'clientes') loadClientes();
|
||||||
else if (id === 'financieros') loadFinancieros();
|
else if (id === 'financieros') loadFinancieros();
|
||||||
else if (id === 'historico') loadHistorico();
|
else if (id === 'historico') loadHistorico();
|
||||||
|
else if (id === 'cortes') loadCortes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.switchTab = switchTab;
|
window.switchTab = switchTab;
|
||||||
@@ -737,6 +751,153 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TAB 6: CORTES DE CAJA
|
||||||
|
// =========================================================================
|
||||||
|
async function loadCortes() {
|
||||||
|
loaded.cortes = true;
|
||||||
|
var dateFrom = document.getElementById('cortes-date-from').value;
|
||||||
|
var dateTo = document.getElementById('cortes-date-to').value;
|
||||||
|
var employeeId = document.getElementById('cortes-employee').value;
|
||||||
|
|
||||||
|
// Cashiers/counters without pos.view can only see their own cuts
|
||||||
|
var u = currentUser();
|
||||||
|
if (isLimitedUser()) {
|
||||||
|
employeeId = u.employeeId || '';
|
||||||
|
var empSel = document.getElementById('cortes-employee');
|
||||||
|
if (empSel) empSel.value = employeeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var kpiEl = document.getElementById('cortes-kpis');
|
||||||
|
var detalleEl = document.getElementById('cortes-detalle');
|
||||||
|
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
|
||||||
|
kpiEl.innerHTML = spinner();
|
||||||
|
detalleEl.innerHTML = spinner();
|
||||||
|
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
|
||||||
|
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
if (dateFrom) params.set('date_from', dateFrom);
|
||||||
|
if (dateTo) params.set('date_to', dateTo);
|
||||||
|
if (employeeId) params.set('employee_id', employeeId);
|
||||||
|
params.set('per_page', '200');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await apiFetch('/pos/api/register/history?' + params.toString());
|
||||||
|
var regs = data.data || [];
|
||||||
|
|
||||||
|
var totalEsperado = 0;
|
||||||
|
var totalCierre = 0;
|
||||||
|
var totalDiferencia = 0;
|
||||||
|
regs.forEach(function(r) {
|
||||||
|
totalEsperado += r.expected_amount || 0;
|
||||||
|
totalCierre += r.closing_amount || 0;
|
||||||
|
totalDiferencia += r.difference || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
kpiEl.innerHTML =
|
||||||
|
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
|
||||||
|
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
|
||||||
|
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
|
||||||
|
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
|
||||||
|
|
||||||
|
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
|
||||||
|
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
|
||||||
|
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||||
|
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
|
||||||
|
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
|
||||||
|
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
|
||||||
|
'<th>Acciones</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
regs.forEach(function(r) {
|
||||||
|
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
|
||||||
|
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
|
||||||
|
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
|
||||||
|
'<td class="td-mono">#' + r.register_number + '</td>' +
|
||||||
|
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
|
||||||
|
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
|
||||||
|
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
|
||||||
|
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
|
||||||
|
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
|
||||||
|
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
|
||||||
|
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
|
||||||
|
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
|
||||||
|
});
|
||||||
|
cHtml += '</tbody></table></div>';
|
||||||
|
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
|
||||||
|
} catch (err) {
|
||||||
|
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
|
||||||
|
detalleEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Detail: sales for a selected cash cut
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async function showCorteVentas(registerId) {
|
||||||
|
var panel = document.getElementById('corte-ventas-detalle');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.style.display = 'block';
|
||||||
|
panel.innerHTML = spinner();
|
||||||
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
|
||||||
|
var methodLabels = {
|
||||||
|
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
|
||||||
|
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
|
||||||
|
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
|
||||||
|
var sales = data.sales || [];
|
||||||
|
var summary = data.summary || {};
|
||||||
|
|
||||||
|
var total = summary.total || 0;
|
||||||
|
var count = summary.count || 0;
|
||||||
|
var byMethod = summary.by_method || {};
|
||||||
|
|
||||||
|
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
|
||||||
|
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
|
||||||
|
|
||||||
|
// Summary by payment method
|
||||||
|
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
|
||||||
|
if (methods.length) {
|
||||||
|
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
|
||||||
|
methods.forEach(function(m) {
|
||||||
|
var label = methodLabels[m[0]] || m[0];
|
||||||
|
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
|
||||||
|
});
|
||||||
|
hHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||||
|
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
|
||||||
|
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
|
||||||
|
'<th class="align-right">Total</th><th>Estado</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
sales.forEach(function(s) {
|
||||||
|
var statusPill = s.status === 'completed' ? 'pill--success' :
|
||||||
|
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
|
||||||
|
var statusLabel = s.status === 'completed' ? 'Completada' :
|
||||||
|
s.status === 'cancelled' ? 'Cancelada' : s.status;
|
||||||
|
var method = methodLabels[s.payment_method] || s.payment_method || '--';
|
||||||
|
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
|
||||||
|
'<td>' + fmtDateTime(s.created_at) + '</td>' +
|
||||||
|
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
|
||||||
|
'<td>' + method + '</td>' +
|
||||||
|
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
|
||||||
|
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
|
||||||
|
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
|
||||||
|
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
|
||||||
|
});
|
||||||
|
hHtml += '</tbody></table></div>';
|
||||||
|
|
||||||
|
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.showCorteVentas = showCorteVentas;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -753,6 +914,9 @@ const Reports = (() => {
|
|||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
var u = currentUser();
|
||||||
|
var limited = isLimitedUser();
|
||||||
|
|
||||||
// Set default date range: first day of current month to today
|
// Set default date range: first day of current month to today
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
@@ -761,6 +925,49 @@ const Reports = (() => {
|
|||||||
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
||||||
if (toEl) toEl.value = now.toISOString().substring(0, 10);
|
if (toEl) toEl.value = now.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
// Set default date range for cortes de caja
|
||||||
|
var cortesFrom = document.getElementById('cortes-date-from');
|
||||||
|
var cortesTo = document.getElementById('cortes-date-to');
|
||||||
|
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
|
||||||
|
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
// Populate cajero filter for cortes de caja (admins/owners only see it)
|
||||||
|
var empSel = document.getElementById('cortes-employee');
|
||||||
|
if (empSel && !limited) {
|
||||||
|
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
|
||||||
|
(data.data || []).forEach(function(e) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = e.id;
|
||||||
|
opt.textContent = e.name;
|
||||||
|
empSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}).catch(function() {});
|
||||||
|
} else if (empSel && limited) {
|
||||||
|
empSel.value = u.employeeId || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
|
||||||
|
if (limited) {
|
||||||
|
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
|
||||||
|
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
});
|
||||||
|
var empFilter = document.getElementById('cortes-employee-filter');
|
||||||
|
if (empFilter) empFilter.style.display = 'none';
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||||
|
var cortesPanel = document.getElementById('panel-cortes');
|
||||||
|
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
|
||||||
|
if (cortesPanel) cortesPanel.classList.add('is-active');
|
||||||
|
if (cortesBtn) {
|
||||||
|
cortesBtn.classList.add('is-active');
|
||||||
|
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
|
||||||
|
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
|
||||||
|
}
|
||||||
|
loadCortes();
|
||||||
|
}
|
||||||
|
|
||||||
// Populate financial period selectors
|
// Populate financial period selectors
|
||||||
var monthSel = document.getElementById('fin-month');
|
var monthSel = document.getElementById('fin-month');
|
||||||
var yearSel = document.getElementById('fin-year');
|
var yearSel = document.getElementById('fin-year');
|
||||||
@@ -784,15 +991,18 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the default active tab (ventas)
|
// Load the default active tab (ventas) only for privileged users
|
||||||
loadVentas();
|
if (!limited) {
|
||||||
|
loadVentas();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init, setTheme, switchTab,
|
init, setTheme, switchTab,
|
||||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
|
||||||
|
showCorteVentas, fmt
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -41,10 +41,32 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
function itemAllowed(id) {
|
function itemAllowed(id) {
|
||||||
if (role === 'owner' || role === 'admin') return true;
|
if (role === 'owner' || role === 'admin') return true;
|
||||||
if (role === 'workshop' || role === 'mechanic') {
|
if (role === 'workshop' || role === 'mechanic') {
|
||||||
return id === 'workshop';
|
// Taller siempre visible; el resto depende de los permisos asignados.
|
||||||
|
var allowed = ['workshop'];
|
||||||
|
var permMap = {
|
||||||
|
'customers.view': 'customers',
|
||||||
|
'inventory.view': 'inventory',
|
||||||
|
'catalog.view': 'catalog',
|
||||||
|
'pos.sell': 'pos',
|
||||||
|
'pos.view': 'pos',
|
||||||
|
'pos.remission': 'remission_notes',
|
||||||
|
'invoicing.view': 'invoicing',
|
||||||
|
'quotations.view': 'quotations',
|
||||||
|
'accounting.view': 'accounting',
|
||||||
|
'reports.view': 'reports'
|
||||||
|
};
|
||||||
|
for (var p in permMap) {
|
||||||
|
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||||
|
allowed.push(permMap[p]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed.indexOf(id) !== -1;
|
||||||
|
}
|
||||||
|
if (role === 'counter') {
|
||||||
|
return ['pos','catalog','inventory','customers','workshop','remission_notes','reports'].indexOf(id) !== -1;
|
||||||
}
|
}
|
||||||
if (role === 'cashier') {
|
if (role === 'cashier') {
|
||||||
return ['dashboard','pos','catalog','inventory','customers'].indexOf(id) !== -1;
|
return ['pos','catalog','inventory','customers','workshop','remission_notes','invoicing','reports'].indexOf(id) !== -1;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -58,6 +80,7 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||||
{ label: _t('nav_management'), items: [
|
{ label: _t('nav_management'), items: [
|
||||||
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||||
|
{ id: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||||
{ id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
{ id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
||||||
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||||
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||||
@@ -65,7 +88,6 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||||
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||||
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||||
hasPerm('fleet.view') ? { id: 'fleet', name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' } : null,
|
|
||||||
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
||||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||||
{ label: _t('nav_system'), items: [
|
{ label: _t('nav_system'), items: [
|
||||||
@@ -132,14 +154,14 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
// Replace existing sidebar
|
// Replace existing sidebar
|
||||||
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
|
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.className = 'pos-sidebar';
|
existing.className = 'pos-sidebar sidebar';
|
||||||
existing.innerHTML = sidebarHtml;
|
existing.innerHTML = sidebarHtml;
|
||||||
existing.removeAttribute('style');
|
existing.removeAttribute('style');
|
||||||
} else {
|
} else {
|
||||||
var el = document.createElement('aside');
|
var el = document.createElement('aside');
|
||||||
el.className = 'pos-sidebar';
|
el.className = 'pos-sidebar sidebar';
|
||||||
el.innerHTML = sidebarHtml;
|
el.innerHTML = sidebarHtml;
|
||||||
document.body.insertBefore(el, document.body.firstChild);
|
document.body.insertBefore(el, document.body.firstChild);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,47 +30,99 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
var user = window.POS_USER || {};
|
var user = window.POS_USER || {};
|
||||||
var role = (user.role || '').toLowerCase();
|
var role = (user.role || '').toLowerCase();
|
||||||
var hidePrices = role === 'workshop' || role === 'mechanic';
|
var isRestricted = role === 'workshop' || role === 'mechanic';
|
||||||
|
var isMechanic = role === 'mechanic';
|
||||||
|
var hidePrices = isRestricted;
|
||||||
var perms = user.permissions || [];
|
var perms = user.permissions || [];
|
||||||
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
// Owner/admin/counter/cashier can create/edit/delete service orders.
|
||||||
|
// Mechanics have limited access: view allowed statuses, change status, edit diagnosis/repair notes.
|
||||||
|
var canEdit = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier';
|
||||||
|
var canCreate = canEdit;
|
||||||
var canDelete = role === 'owner' || role === 'admin';
|
var canDelete = role === 'owner' || role === 'admin';
|
||||||
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
|
||||||
|
var canChangeStatus = canEdit || isMechanic;
|
||||||
|
|
||||||
var COLUMNS = [
|
var COLUMNS = [
|
||||||
{key: 'received', label: 'Recibido'},
|
{key: 'por_revisar', label: 'Por revisar'},
|
||||||
{key: 'diagnosis', label: 'Diagnóstico'},
|
{key: 'en_revision', label: 'En revisión'},
|
||||||
{key: 'waiting_parts', label: 'Espera refacciones'},
|
{key: 'revisada', label: 'Revisada'},
|
||||||
{key: 'repair', label: 'En reparación'},
|
{key: 'cotizada', label: 'Cotizada'},
|
||||||
{key: 'ready', label: 'Listo'},
|
{key: 'por_autorizar', label: 'Por autorizar'},
|
||||||
{key: 'delivered', label: 'Entregado'},
|
{key: 'autorizada', label: 'Autorizada'},
|
||||||
|
{key: 'autorizacion_parcial', label: 'Autorización parcial'},
|
||||||
|
{key: 'en_reparacion', label: 'En reparación'},
|
||||||
|
{key: 'reparada', label: 'Reparada'},
|
||||||
|
{key: 'por_entregar', label: 'Por entregar'},
|
||||||
|
{key: 'entregado', label: 'Entregado'},
|
||||||
|
{key: 'por_enviar', label: 'Por enviar'},
|
||||||
|
{key: 'enviado', label: 'Enviado'},
|
||||||
|
{key: 'por_facturar', label: 'Por facturar'},
|
||||||
|
{key: 'facturada', label: 'Facturada'},
|
||||||
|
{key: 'por_recolectar', label: 'Por recolectar'},
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_LABELS = {
|
var STATUS_LABELS = {
|
||||||
received: 'Recibido',
|
por_revisar: 'Por revisar',
|
||||||
diagnosis: 'Diagnóstico',
|
en_revision: 'En revisión',
|
||||||
waiting_parts: 'Espera refacciones',
|
revisada: 'Revisada',
|
||||||
repair: 'En reparación',
|
cotizada: 'Cotizada',
|
||||||
ready: 'Listo',
|
por_autorizar: 'Por autorizar',
|
||||||
delivered: 'Entregado',
|
autorizada: 'Autorizada',
|
||||||
cancelled: 'Cancelado'
|
autorizacion_parcial: 'Autorización parcial',
|
||||||
|
en_reparacion: 'En reparación',
|
||||||
|
reparada: 'Reparada',
|
||||||
|
por_entregar: 'Por entregar',
|
||||||
|
entregado: 'Entregado',
|
||||||
|
por_enviar: 'Por enviar',
|
||||||
|
enviado: 'Enviado',
|
||||||
|
por_facturar: 'Por facturar',
|
||||||
|
facturada: 'Facturada',
|
||||||
|
por_recolectar: 'Por recolectar',
|
||||||
|
cancelada: 'Cancelada'
|
||||||
};
|
};
|
||||||
|
|
||||||
var VALID_NEXT = {
|
var VALID_NEXT = {
|
||||||
received: ['diagnosis', 'cancelled'],
|
por_revisar: ['en_revision', 'cancelada'],
|
||||||
diagnosis: ['waiting_parts', 'repair', 'cancelled'],
|
en_revision: ['revisada', 'por_revisar', 'cancelada'],
|
||||||
waiting_parts: ['repair', 'cancelled'],
|
revisada: ['cotizada', 'en_revision', 'cancelada'],
|
||||||
repair: ['ready', 'cancelled'],
|
cotizada: ['por_autorizar', 'revisada', 'cancelada'],
|
||||||
ready: ['delivered', 'cancelled'],
|
por_autorizar: ['autorizada', 'autorizacion_parcial', 'cotizada', 'cancelada'],
|
||||||
delivered: [],
|
autorizada: ['en_reparacion', 'por_autorizar', 'cancelada'],
|
||||||
cancelled: []
|
autorizacion_parcial: ['en_reparacion', 'por_autorizar', 'cancelada'],
|
||||||
|
en_reparacion: ['reparada', 'por_autorizar', 'cancelada'],
|
||||||
|
reparada: ['por_entregar', 'en_reparacion', 'cancelada'],
|
||||||
|
por_entregar: ['entregado', 'por_enviar', 'reparada', 'cancelada'],
|
||||||
|
por_enviar: ['enviado', 'por_entregar', 'cancelada'],
|
||||||
|
enviado: ['entregado', 'por_enviar', 'cancelada'],
|
||||||
|
por_recolectar: ['por_revisar', 'cancelada'],
|
||||||
|
por_facturar: ['facturada', 'cancelada'],
|
||||||
|
entregado: ['por_facturar'],
|
||||||
|
facturada: [],
|
||||||
|
cancelada: []
|
||||||
};
|
};
|
||||||
|
|
||||||
var DELIVERY_LABELS = {
|
var DELIVERY_LABELS = {
|
||||||
pickup: 'Pasa cliente',
|
pickup: 'Mostrador',
|
||||||
delivery: 'Envío a domicilio',
|
delivery: 'Envío a domicilio',
|
||||||
courier: 'Motociclista'
|
courier: 'Motociclista'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var ITEM_STATUS_LABELS = {
|
||||||
|
por_revisar: 'Por revisar',
|
||||||
|
revisando: 'Revisando',
|
||||||
|
revisado: 'Revisado',
|
||||||
|
cotizado: 'Cotizado',
|
||||||
|
por_autorizar: 'Por autorizar',
|
||||||
|
autorizado: 'Autorizado',
|
||||||
|
en_reparacion: 'En reparación',
|
||||||
|
reparado: 'Reparado',
|
||||||
|
por_entregar: 'Por entregar',
|
||||||
|
entregado: 'Entregado',
|
||||||
|
por_enviar: 'Por enviar',
|
||||||
|
enviado: 'Enviado',
|
||||||
|
cancelado: 'Cancelado'
|
||||||
|
};
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -88,6 +140,16 @@ var Workshop = (function() {
|
|||||||
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price based on customer tier: base price (price_1) with tier discount.
|
||||||
|
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||||
|
function priceForTier(basePrice, tier) {
|
||||||
|
var p = parseFloat(basePrice) || 0;
|
||||||
|
var t = parseInt(tier, 10) || 1;
|
||||||
|
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||||
|
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
var dt = new Date(d);
|
var dt = new Date(d);
|
||||||
@@ -126,6 +188,15 @@ var Workshop = (function() {
|
|||||||
if (savedView) currentView = savedView;
|
if (savedView) currentView = savedView;
|
||||||
bindFilters();
|
bindFilters();
|
||||||
bindDeliverySelect();
|
bindDeliverySelect();
|
||||||
|
if (!canCreate) {
|
||||||
|
var btnNewOrder = document.getElementById('btnNewOrder');
|
||||||
|
if (btnNewOrder) btnNewOrder.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (isRestricted) {
|
||||||
|
document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
var searchInput = document.getElementById('filterSearch');
|
||||||
|
if (searchInput) searchInput.placeholder = 'Buscar orden';
|
||||||
|
}
|
||||||
if (hidePrices) {
|
if (hidePrices) {
|
||||||
var btnCatalog = document.getElementById('btnCatalog');
|
var btnCatalog = document.getElementById('btnCatalog');
|
||||||
if (btnCatalog) btnCatalog.style.display = 'none';
|
if (btnCatalog) btnCatalog.style.display = 'none';
|
||||||
@@ -151,11 +222,19 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function bindDeliverySelect() {
|
function bindDeliverySelect() {
|
||||||
var sel = document.getElementById('noDelivery');
|
var sel = document.getElementById('noDelivery');
|
||||||
if (!sel) return;
|
if (sel) {
|
||||||
sel.addEventListener('change', function() {
|
sel.addEventListener('change', function() {
|
||||||
var cf = document.getElementById('courierField');
|
var cf = document.getElementById('courierField');
|
||||||
if (cf) cf.style.display = sel.value === 'courier' ? 'block' : 'none';
|
if (cf) cf.style.display = (sel.value === 'delivery' || sel.value === 'courier') ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
var eoSel = document.getElementById('eoDelivery');
|
||||||
|
if (eoSel) {
|
||||||
|
eoSel.addEventListener('change', function() {
|
||||||
|
var cf = document.getElementById('eoCourierField');
|
||||||
|
if (cf) cf.style.display = (eoSel.value === 'delivery' || eoSel.value === 'courier') ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
function debounce(fn, ms) {
|
||||||
@@ -172,9 +251,9 @@ var Workshop = (function() {
|
|||||||
fetch(API + '/kanban/summary', {headers: headers()})
|
fetch(API + '/kanban/summary', {headers: headers()})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
document.getElementById('statReceived').textContent = fmt(d.received || 0);
|
document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0);
|
||||||
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
|
document.getElementById('statRepair').textContent = fmt((d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + (d.cotizada || 0) + (d.por_autorizar || 0));
|
||||||
document.getElementById('statReady').textContent = fmt(d.ready || 0);
|
document.getElementById('statReady').textContent = fmt(d.por_entregar || 0);
|
||||||
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
|
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
@@ -243,20 +322,33 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
var body = document.getElementById('listBody');
|
var body = document.getElementById('listBody');
|
||||||
|
var fullCols = 8;
|
||||||
|
var restrictedCols = 4;
|
||||||
|
var cols = isRestricted ? restrictedCols : fullCols;
|
||||||
if (!orders.length) {
|
if (!orders.length) {
|
||||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
body.innerHTML = '<tr><td colspan="' + cols + '" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = orders.map(function(o) {
|
body.innerHTML = orders.map(function(o) {
|
||||||
var vehicle = esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || ''));
|
var statusCell = '<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>';
|
||||||
|
var actionCell = '<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>';
|
||||||
|
var base = '<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||||
|
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||||
|
statusCell +
|
||||||
|
actionCell;
|
||||||
|
if (isRestricted) {
|
||||||
|
return '<tr>' + base + '</tr>';
|
||||||
|
}
|
||||||
|
var vehicle = esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—'));
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td>' + esc(o.branch_name || '—') + '</td>' +
|
'<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||||
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||||
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
|
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
|
||||||
|
'<td>' + esc(o.workshop_name || '—') + '</td>' +
|
||||||
'<td>' + vehicle + '</td>' +
|
'<td>' + vehicle + '</td>' +
|
||||||
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
|
statusCell +
|
||||||
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
|
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
|
||||||
'<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>' +
|
actionCell +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
if (hidePrices) {
|
if (hidePrices) {
|
||||||
@@ -287,7 +379,8 @@ var Workshop = (function() {
|
|||||||
function renderKanban() {
|
function renderKanban() {
|
||||||
var board = document.getElementById('kanbanBoard');
|
var board = document.getElementById('kanbanBoard');
|
||||||
board.innerHTML = '';
|
board.innerHTML = '';
|
||||||
COLUMNS.forEach(function(col) {
|
var hiddenCols = isMechanic ? ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'] : [];
|
||||||
|
COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) {
|
||||||
var colOrders = orders.filter(function(o) { return o.status === col.key; });
|
var colOrders = orders.filter(function(o) { return o.status === col.key; });
|
||||||
var colEl = document.createElement('div');
|
var colEl = document.createElement('div');
|
||||||
colEl.className = 'kanban-column';
|
colEl.className = 'kanban-column';
|
||||||
@@ -322,13 +415,22 @@ var Workshop = (function() {
|
|||||||
card.className = 'kanban-card';
|
card.className = 'kanban-card';
|
||||||
card.onclick = function() { openDetail(o.id); };
|
card.onclick = function() { openDetail(o.id); };
|
||||||
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
|
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
|
||||||
|
if (isRestricted) {
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="kanban-card__header">' +
|
||||||
|
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||||
|
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="kanban-card__branch">' + esc(o.branch_name || '—') + '</div>';
|
||||||
|
return card;
|
||||||
|
}
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<div class="kanban-card__header">' +
|
'<div class="kanban-card__header">' +
|
||||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||||
' <span class="kanban-card__priority badge badge--' + esc(o.priority) + '">' + esc(priorityLabel(o.priority)) + '</span>' +
|
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
|
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
|
||||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
'<div class="kanban-card__vehicle">' + esc(o.vehicle_description || o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||||
'<div class="kanban-card__meta">' +
|
'<div class="kanban-card__meta">' +
|
||||||
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
||||||
priceHtml +
|
priceHtml +
|
||||||
@@ -355,47 +457,56 @@ var Workshop = (function() {
|
|||||||
var activeTab = document.querySelector('.so-tabs__btn.is-active');
|
var activeTab = document.querySelector('.so-tabs__btn.is-active');
|
||||||
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
||||||
|
|
||||||
|
var mechanicAssignHtml = isMechanic ?
|
||||||
|
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' +
|
||||||
|
'<input id="mechanicNameInput" class="form-input" style="width:auto;min-width:180px;" value="' + esc(o.mechanic_name || '') + '" placeholder="Nombre del mecánico" />' +
|
||||||
|
' <button class="btn btn--sm btn--secondary" onclick="Workshop.saveMechanicName()">Guardar</button></div>' :
|
||||||
|
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' + esc(o.mechanic_name || o.employee_name || 'Sin asignar') + '</div>';
|
||||||
|
|
||||||
var html =
|
var html =
|
||||||
'<div class="so-detail-header">' +
|
'<div class="so-detail-header">' +
|
||||||
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
|
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
|
||||||
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
|
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
|
||||||
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
|
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
|
||||||
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
|
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
|
||||||
|
mechanicAssignHtml +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
||||||
|
(isRestricted ? '' :
|
||||||
'<div class="so-detail-info">' +
|
'<div class="so-detail-info">' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||||
|
' <div class="so-detail__field"><span class="so-detail__label">Taller</span><span class="so-detail__value">' + esc(o.workshop_name || '—') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—')) + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
|
||||||
|
' <div class="so-detail__field"><span class="so-detail__label">Factura</span><span class="so-detail__value">' + (o.requires_invoice ? 'Sí requiere' : 'No requiere') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
|
||||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
|
||||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
|
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
|
||||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
|
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
|
||||||
'</div>' +
|
'</div>') +
|
||||||
|
|
||||||
|
(isRestricted ? '' :
|
||||||
'<div class="so-tabs">' +
|
'<div class="so-tabs">' +
|
||||||
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
|
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
|
||||||
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
|
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
|
||||||
'</div>' +
|
'</div>') +
|
||||||
|
|
||||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' || isRestricted ? '' : 'style="display:none;"') + '>' +
|
||||||
' <div class="so-detail__section">' +
|
' <div class="so-detail__section">' +
|
||||||
' <h3>Notas</h3>' +
|
' <h3>Notas</h3>' +
|
||||||
' <div class="so-notes-grid">' +
|
' <div class="so-notes-grid">' +
|
||||||
' <label class="form-label">Recepción</label>' +
|
' <label class="form-label">Recepción</label>' +
|
||||||
' <textarea id="noteReception" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.reception_notes || '') + '</textarea>' +
|
' <textarea id="noteReception" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.reception_notes || '') + '</textarea>' +
|
||||||
' <label class="form-label">Diagnóstico</label>' +
|
' <label class="form-label">Diagnóstico</label>' +
|
||||||
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
|
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
|
||||||
' <label class="form-label">Reparación</label>' +
|
' <label class="form-label">Reparación</label>' +
|
||||||
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
|
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
|
||||||
' <label class="form-label">Entrega</label>' +
|
' <label class="form-label">Entrega</label>' +
|
||||||
' <textarea id="noteDelivery" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.delivery_notes || '') + '</textarea>' +
|
' <textarea id="noteDelivery" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.delivery_notes || '') + '</textarea>' +
|
||||||
' </div>' +
|
' </div>' +
|
||||||
(canEdit ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
|
(canEdit || isMechanic ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
|
||||||
' </div>' +
|
' </div>' +
|
||||||
' <div class="so-detail__section">' +
|
' <div class="so-detail__section">' +
|
||||||
' <h3>Bitácora</h3>' +
|
' <h3>Bitácora</h3>' +
|
||||||
@@ -403,16 +514,18 @@ var Workshop = (function() {
|
|||||||
' </div>' +
|
' </div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
||||||
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
(isRestricted ? '' : '<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
||||||
renderArticles(o) +
|
renderArticles(o) +
|
||||||
'</div>';
|
'</div>');
|
||||||
|
|
||||||
document.getElementById('detailBody').innerHTML = html;
|
document.getElementById('detailBody').innerHTML = html;
|
||||||
|
|
||||||
// Footer actions
|
// Footer actions
|
||||||
var footer = document.getElementById('detailFooter');
|
var footer = document.getElementById('detailFooter');
|
||||||
var allowedNext = VALID_NEXT[o.status] || [];
|
var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) {
|
||||||
var statusHtml = canEdit && allowedNext.length ?
|
return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1;
|
||||||
|
});
|
||||||
|
var statusHtml = canChangeStatus && allowedNext.length ?
|
||||||
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||||
'<option value="' + esc(o.status) + '" selected>' + esc(STATUS_LABELS[o.status] || o.status) + '</option>' +
|
'<option value="' + esc(o.status) + '" selected>' + esc(STATUS_LABELS[o.status] || o.status) + '</option>' +
|
||||||
@@ -424,10 +537,11 @@ var Workshop = (function() {
|
|||||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||||
(canEdit ? '<button class="btn btn--secondary" onclick="Workshop.openEditOrderModal()">Editar orden</button>' : '') +
|
(canEdit ? '<button class="btn btn--secondary" onclick="Workshop.openEditOrderModal()">Editar orden</button>' : '') +
|
||||||
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
|
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
|
||||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
(isRestricted ? '' : '<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||||
'Imprimir orden</button>' +
|
'Imprimir orden</button>') +
|
||||||
(canEdit && canSell && o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
(canEdit && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||||
|
(canEdit && canSell && !o.sale_id && o.status !== 'cancelled' ? '<button class="btn btn--secondary" onclick="Workshop.convertToRemission()">Generar nota de remisión</button>' : '') +
|
||||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,93 +553,105 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function renderBitacora(history) {
|
function renderBitacora(history) {
|
||||||
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
|
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
|
||||||
|
var vehicle = currentOrder ? (currentOrder.vehicle_description || (currentOrder.vehicle_plate ? currentOrder.vehicle_plate + ' ' + (currentOrder.vehicle_make || '') + ' ' + (currentOrder.vehicle_model || '') : null)) : null;
|
||||||
var rows = history.map(function(h) {
|
var rows = history.map(function(h) {
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
|
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
|
||||||
'<td>' + fmtDate(h.created_at) + '</td>' +
|
'<td>' + fmtDate(h.created_at) + '</td>' +
|
||||||
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
|
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
|
||||||
|
(isMechanic && vehicle ? '<td>' + esc(vehicle) + '</td>' : '') +
|
||||||
'<td>' + esc(h.notes || '—') + '</td>' +
|
'<td>' + esc(h.notes || '—') + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th><th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
var vehicleTh = isMechanic && vehicle ? '<th>Vehículo</th>' : '';
|
||||||
|
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th>' + vehicleTh + '<th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanicName(id) {
|
||||||
|
var e = employees.find(function(x) { return x.id === id; });
|
||||||
|
return e ? e.name : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEmployees() {
|
||||||
|
if (employees && employees.length) return Promise.resolve(employees);
|
||||||
|
return fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
employees = (d.data || d.employees || []);
|
||||||
|
return employees;
|
||||||
|
})
|
||||||
|
.catch(function() { employees = []; return employees; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateMechanicSelect(selectId, selectedId) {
|
||||||
|
var sel = document.getElementById(selectId);
|
||||||
|
if (!sel) return;
|
||||||
|
ensureEmployees().then(function() {
|
||||||
|
sel.innerHTML = '<option value="">— Sin asignar —</option>' +
|
||||||
|
employees
|
||||||
|
.filter(function(e) { return e.is_active && (e.role === 'mechanic' || e.role === 'workshop'); })
|
||||||
|
.map(function(e) {
|
||||||
|
return '<option value="' + e.id + '"' + (e.id === selectedId ? ' selected' : '') + '>' + esc(e.name) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderArticles(o) {
|
function renderArticles(o) {
|
||||||
|
var colCount = (hidePrices ? 5 : 6) - (isRestricted ? 1 : 0);
|
||||||
var partsRows = (o.items || []).map(function(it) {
|
var partsRows = (o.items || []).map(function(it) {
|
||||||
var priceCells = hidePrices ? '' :
|
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(it.unit_price) + '</td>';
|
||||||
'<td>' + fmtMoney(it.unit_price) + '</td>';
|
var mechanicCells = isRestricted ? '' : '<td>' + esc(mechanicName(it.mechanic_id)) + '</td>';
|
||||||
var actionCell = canEdit && it.status !== 'cancelled' ?
|
var actionCell = canEdit && it.status !== 'cancelado' ?
|
||||||
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button></td>' : '<td></td>';
|
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.editItemInline(' + it.id + ')">Editar</button></td>' : '<td></td>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||||
'<td>' + fmt(it.quantity) + '</td>' +
|
'<td>' + fmt(it.quantity) + '</td>' +
|
||||||
priceCells +
|
priceCells +
|
||||||
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
mechanicCells +
|
||||||
|
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(ITEM_STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
||||||
|
'<td>' + esc(it.observations || '') + '</td>' +
|
||||||
actionCell +
|
actionCell +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + '<th>Estado</th><th></th></tr>';
|
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + (isRestricted ? '' : '<th>Mecánico</th>') + '<th>Estado</th><th>Observaciones</th><th></th></tr>';
|
||||||
|
|
||||||
var laborRows = (o.labor || []).map(function(l) {
|
|
||||||
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(l.hourly_rate) + '</td><td>' + fmtMoney(l.total_cost) + '</td>';
|
|
||||||
return '<tr>' +
|
|
||||||
'<td>' + esc(l.description) + '</td>' +
|
|
||||||
'<td>' + fmt(l.hours) + '</td>' +
|
|
||||||
priceCells +
|
|
||||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + esc(STATUS_LABELS[l.status] || l.status) + '</span></td>' +
|
|
||||||
'</tr>';
|
|
||||||
}).join('');
|
|
||||||
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
|
||||||
|
|
||||||
var addParts = canEdit ?
|
var addParts = canEdit ?
|
||||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
|
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
|
||||||
' <div style="position:relative;flex:1;min-width:200px;">' +
|
' <div style="position:relative;flex:1;min-width:160px;">' +
|
||||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" autocomplete="off" oninput="Workshop.searchItemsForSO()" />' +
|
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" autocomplete="off" oninput="Workshop.searchItemsForSO()" />' +
|
||||||
' <div id="itemSearchResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:220px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>' +
|
' <div id="itemSearchResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:220px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>' +
|
||||||
' </div>' +
|
' </div>' +
|
||||||
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:80px;" />' +
|
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:70px;" />' +
|
||||||
|
(hidePrices ? '' : '<input class="form-input" id="newItemPrice" type="number" step="0.01" placeholder="Precio" style="width:90px;" />') +
|
||||||
|
' <select class="form-input" id="newItemMechanic" style="width:auto;"><option value="">— Mecánico —</option></select>' +
|
||||||
|
' <select class="form-input" id="newItemStatus" style="width:auto;">' +
|
||||||
|
Object.keys(ITEM_STATUS_LABELS).map(function(s) { return '<option value="' + s + '">' + esc(ITEM_STATUS_LABELS[s]) + '</option>'; }).join('') +
|
||||||
|
' </select>' +
|
||||||
|
' <input class="form-input" id="newItemObs" placeholder="Observaciones" style="min-width:140px;flex:1;" />' +
|
||||||
' <button class="btn btn--secondary" onclick="Workshop.addSelectedItem()">Agregar</button>' +
|
' <button class="btn btn--secondary" onclick="Workshop.addSelectedItem()">Agregar</button>' +
|
||||||
'</div>' : '';
|
'</div>' : '';
|
||||||
|
|
||||||
var addLabor = canEdit ?
|
|
||||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;">' +
|
|
||||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
|
||||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;min-width:160px;" />' +
|
|
||||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
|
||||||
(hidePrices ? '' : '<input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />') +
|
|
||||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
|
||||||
'</div>' : '';
|
|
||||||
|
|
||||||
var html =
|
var html =
|
||||||
'<div class="so-detail__section">' +
|
'<div class="so-detail__section">' +
|
||||||
' <h3>Refacciones</h3>' +
|
' <h3>Artículos</h3>' +
|
||||||
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + (hidePrices ? 4 : 5) + '" style="text-align:center;">Sin refacciones</td></tr>') + '</tbody></table>' +
|
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + colCount + '" style="text-align:center;">Sin artículos</td></tr>') + '</tbody></table>' +
|
||||||
addParts +
|
addParts +
|
||||||
'</div>' +
|
|
||||||
'<div class="so-detail__section">' +
|
|
||||||
' <h3>Mano de obra</h3>' +
|
|
||||||
' <table class="data-table"><thead>' + laborHeader + '</thead><tbody>' + (laborRows || '<tr><td colspan="' + (hidePrices ? 3 : 5) + '" style="text-align:center;">Sin mano de obra</td></tr>') + '</tbody></table>' +
|
|
||||||
addLabor +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// schedule labor catalog select population after DOM insertion
|
// populate mechanic select after DOM insertion
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
var sel = document.getElementById('laborCatalogSelect');
|
ensureEmployees().then(function() {
|
||||||
if (!sel || sel.dataset.populated) return;
|
var sel = document.getElementById('newItemMechanic');
|
||||||
catalog.forEach(function(c) {
|
if (!sel || sel.dataset.populated) return;
|
||||||
var opt = document.createElement('option');
|
employees.forEach(function(e) {
|
||||||
opt.value = JSON.stringify(c);
|
if (!e.is_active) return;
|
||||||
opt.textContent = c.name + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
|
var opt = document.createElement('option');
|
||||||
sel.appendChild(opt);
|
opt.value = e.id;
|
||||||
|
opt.textContent = e.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.dataset.populated = '1';
|
||||||
});
|
});
|
||||||
sel.dataset.populated = '1';
|
|
||||||
sel.onchange = function() {
|
|
||||||
if (!sel.value) return;
|
|
||||||
var c = JSON.parse(sel.value);
|
|
||||||
document.getElementById('laborDesc').value = c.name;
|
|
||||||
document.getElementById('laborHours').value = c.suggested_hours;
|
|
||||||
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
|
|
||||||
};
|
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
@@ -559,12 +685,9 @@ var Workshop = (function() {
|
|||||||
async function populateEditOrderModal() {
|
async function populateEditOrderModal() {
|
||||||
var o = currentOrder;
|
var o = currentOrder;
|
||||||
selectedEditCustomer = o.customer_id ? {id: o.customer_id, name: o.customer_name || ''} : null;
|
selectedEditCustomer = o.customer_id ? {id: o.customer_id, name: o.customer_name || ''} : null;
|
||||||
selectedEditVehicle = o.vehicle_id ? {id: o.vehicle_id, label: (o.vehicle_plate || '') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')} : null;
|
|
||||||
|
|
||||||
document.getElementById('eoCustomerSearch').value = selectedEditCustomer ? selectedEditCustomer.name : '';
|
document.getElementById('eoCustomerSearch').value = selectedEditCustomer ? selectedEditCustomer.name : '';
|
||||||
document.getElementById('eoCustomerId').value = selectedEditCustomer ? selectedEditCustomer.id : '';
|
document.getElementById('eoCustomerId').value = selectedEditCustomer ? selectedEditCustomer.id : '';
|
||||||
document.getElementById('eoVehicleSearch').value = selectedEditVehicle ? selectedEditVehicle.label.trim() : '';
|
|
||||||
document.getElementById('eoVehicleId').value = selectedEditVehicle ? selectedEditVehicle.id : '';
|
|
||||||
|
|
||||||
// Branches
|
// Branches
|
||||||
var branchSel = document.getElementById('eoBranch');
|
var branchSel = document.getElementById('eoBranch');
|
||||||
@@ -572,25 +695,26 @@ var Workshop = (function() {
|
|||||||
return '<option value="' + b.id + '"' + (b.id === o.branch_id ? ' selected' : '') + '>' + esc(b.name) + '</option>';
|
return '<option value="' + b.id + '"' + (b.id === o.branch_id ? ' selected' : '') + '>' + esc(b.name) + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Mechanics/employees
|
document.getElementById('eoWorkshopName').value = o.workshop_name || '';
|
||||||
var mechSel = document.getElementById('eoMechanic');
|
document.getElementById('eoCustomerAddress').value = o.customer_address || '';
|
||||||
mechSel.innerHTML = '<option value="">— Ninguno —</option>';
|
document.getElementById('eoCustomerPhone').value = o.customer_phone || '';
|
||||||
try {
|
document.getElementById('eoVehicleDescription').value = o.vehicle_description || '';
|
||||||
var res = await fetch('/pos/api/config/employees', {headers: headers()});
|
|
||||||
var json = await res.json();
|
// Delivery / courier
|
||||||
(json.data || []).forEach(function(e) {
|
var deliverySel = document.getElementById('eoDelivery');
|
||||||
if (!e.is_active) return;
|
deliverySel.value = o.delivery_method || '';
|
||||||
mechSel.innerHTML += '<option value="' + e.id + '"' + (e.id === o.employee_id ? ' selected' : '') + '>' + esc(e.name) + '</option>';
|
var courierField = document.getElementById('eoCourierField');
|
||||||
});
|
var courierSel = document.getElementById('eoCourier');
|
||||||
} catch (e) {}
|
courierSel.innerHTML = couriers.map(function(c) {
|
||||||
|
return '<option value="' + c.id + '"' + (c.id === o.courier_id ? ' selected' : '') + '>' + esc(c.name) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
courierField.style.display = (o.delivery_method === 'delivery' || o.delivery_method === 'courier') ? 'block' : 'none';
|
||||||
|
|
||||||
document.getElementById('eoPriority').value = o.priority || 'normal';
|
|
||||||
document.getElementById('eoFuelLevel').value = o.fuel_level || '';
|
|
||||||
document.getElementById('eoMileageIn').value = o.mileage_in != null ? o.mileage_in : '';
|
|
||||||
document.getElementById('eoMileageOut').value = o.mileage_out != null ? o.mileage_out : '';
|
|
||||||
document.getElementById('eoEstimatedCompletion').value = toDatetimeLocal(o.estimated_completion);
|
|
||||||
document.getElementById('eoEstimatedCost').value = o.estimated_cost != null ? o.estimated_cost : '';
|
document.getElementById('eoEstimatedCost').value = o.estimated_cost != null ? o.estimated_cost : '';
|
||||||
|
populateMechanicSelect('eoMechanic', o.employee_id || null);
|
||||||
|
document.getElementById('eoMechanicName').value = o.mechanic_name || '';
|
||||||
document.getElementById('eoNotes').value = o.reception_notes || '';
|
document.getElementById('eoNotes').value = o.reception_notes || '';
|
||||||
|
document.getElementById('eoRequiresInvoice').checked = !!o.requires_invoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideEditSearchResults(type) {
|
function hideEditSearchResults(type) {
|
||||||
@@ -638,36 +762,9 @@ var Workshop = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchVehiclesForSO() {
|
function searchVehiclesForSO() {
|
||||||
var input = document.getElementById('eoVehicleSearch');
|
// Fleet module removed; vehicle is free-text in the taller flow.
|
||||||
var box = document.getElementById('eoVehicleResults');
|
var box = document.getElementById('eoVehicleResults');
|
||||||
var q = input.value.trim();
|
if (box) { box.style.display = 'none'; box.innerHTML = ''; }
|
||||||
selectedEditVehicle = null;
|
|
||||||
document.getElementById('eoVehicleId').value = '';
|
|
||||||
if (!q || q.length < 2) {
|
|
||||||
box.style.display = 'none'; box.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(vehicleSearchTimer);
|
|
||||||
vehicleSearchTimer = setTimeout(function() {
|
|
||||||
fetch('/pos/api/fleet/vehicles?q=' + encodeURIComponent(q) + '&per_page=20&active_only=true', {headers: headers()})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
|
||||||
var items = d.data || [];
|
|
||||||
if (!items.length) {
|
|
||||||
box.innerHTML = '<div style="padding:var(--space-2);color:var(--color-text-muted);">Sin resultados</div>';
|
|
||||||
box.style.display = 'block'; return;
|
|
||||||
}
|
|
||||||
box.innerHTML = items.map(function(v) {
|
|
||||||
var label = (v.plate || '') + ' · ' + (v.make || '') + ' ' + (v.model || '');
|
|
||||||
return '<div class="so-search-result" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectEditVehicle(' + v.id + ', \'' + escJs(label) + '\')">' +
|
|
||||||
'<strong>' + esc(v.plate || '') + '</strong>' +
|
|
||||||
'<small>' + esc(v.make || '') + ' ' + esc(v.model || '') + ' · ' + esc(v.owner_name || '') + '</small>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
box.style.display = 'block';
|
|
||||||
})
|
|
||||||
.catch(function() { box.style.display = 'none'; });
|
|
||||||
}, 250);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEditVehicle(id, label) {
|
function selectEditVehicle(id, label) {
|
||||||
@@ -680,19 +777,22 @@ var Workshop = (function() {
|
|||||||
async function saveOrderChanges() {
|
async function saveOrderChanges() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
var customerId = selectedEditCustomer ? selectedEditCustomer.id : parseInt(document.getElementById('eoCustomerId').value, 10) || null;
|
var customerId = selectedEditCustomer ? selectedEditCustomer.id : parseInt(document.getElementById('eoCustomerId').value, 10) || null;
|
||||||
var vehicleId = selectedEditVehicle ? selectedEditVehicle.id : parseInt(document.getElementById('eoVehicleId').value, 10) || null;
|
var delivery = document.getElementById('eoDelivery').value;
|
||||||
|
var mechanicVal = document.getElementById('eoMechanic').value;
|
||||||
var payload = {
|
var payload = {
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
vehicle_id: vehicleId,
|
|
||||||
branch_id: parseInt(document.getElementById('eoBranch').value, 10) || null,
|
branch_id: parseInt(document.getElementById('eoBranch').value, 10) || null,
|
||||||
employee_id: parseInt(document.getElementById('eoMechanic').value, 10) || null,
|
workshop_name: document.getElementById('eoWorkshopName').value.trim() || null,
|
||||||
priority: document.getElementById('eoPriority').value,
|
customer_address: document.getElementById('eoCustomerAddress').value.trim() || null,
|
||||||
fuel_level: document.getElementById('eoFuelLevel').value || null,
|
customer_phone: document.getElementById('eoCustomerPhone').value.trim() || null,
|
||||||
mileage_in: document.getElementById('eoMileageIn').value ? parseInt(document.getElementById('eoMileageIn').value, 10) : null,
|
vehicle_description: document.getElementById('eoVehicleDescription').value.trim() || null,
|
||||||
mileage_out: document.getElementById('eoMileageOut').value ? parseInt(document.getElementById('eoMileageOut').value, 10) : null,
|
delivery_method: delivery || null,
|
||||||
estimated_completion: document.getElementById('eoEstimatedCompletion').value || null,
|
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('eoCourier').value, 10) || null) : null,
|
||||||
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
|
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
|
||||||
reception_notes: document.getElementById('eoNotes').value
|
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
|
||||||
|
mechanic_name: document.getElementById('eoMechanicName').value.trim() || null,
|
||||||
|
reception_notes: document.getElementById('eoNotes').value,
|
||||||
|
requires_invoice: document.getElementById('eoRequiresInvoice').checked
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await api('PUT', '/' + currentOrderId, payload);
|
await api('PUT', '/' + currentOrderId, payload);
|
||||||
@@ -832,8 +932,11 @@ var Workshop = (function() {
|
|||||||
opt.textContent = label.trim();
|
opt.textContent = label.trim();
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
sel.value = res.id;
|
sel.value = res.id;
|
||||||
|
document.getElementById('noWorkshopName').value = name;
|
||||||
|
document.getElementById('noCustomerPhone').value = payload.phone || '';
|
||||||
|
document.getElementById('noCustomerAddress').value = payload.address || '';
|
||||||
if (typeof customers !== 'undefined') {
|
if (typeof customers !== 'undefined') {
|
||||||
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc});
|
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc, address: payload.address});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectedEditCustomer = {id: res.id, name: name};
|
selectedEditCustomer = {id: res.id, name: name};
|
||||||
@@ -867,25 +970,46 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function saveNotes() {
|
function saveNotes() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
var payload = {
|
var payload;
|
||||||
reception_notes: document.getElementById('noteReception').value,
|
if (isMechanic) {
|
||||||
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
payload = {
|
||||||
repair_notes: document.getElementById('noteRepair').value,
|
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
||||||
delivery_notes: document.getElementById('noteDelivery').value
|
repair_notes: document.getElementById('noteRepair').value
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
payload = {
|
||||||
|
reception_notes: document.getElementById('noteReception').value,
|
||||||
|
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
||||||
|
repair_notes: document.getElementById('noteRepair').value,
|
||||||
|
delivery_notes: document.getElementById('noteDelivery').value
|
||||||
|
};
|
||||||
|
}
|
||||||
api('PUT', '/' + currentOrderId, payload)
|
api('PUT', '/' + currentOrderId, payload)
|
||||||
.then(function() {
|
.then(function() {
|
||||||
alert('Notas guardadas');
|
alert('Notas guardadas');
|
||||||
if (currentOrder) {
|
if (currentOrder) {
|
||||||
currentOrder.reception_notes = payload.reception_notes;
|
if ('reception_notes' in payload) currentOrder.reception_notes = payload.reception_notes;
|
||||||
currentOrder.diagnosis_notes = payload.diagnosis_notes;
|
if ('diagnosis_notes' in payload) currentOrder.diagnosis_notes = payload.diagnosis_notes;
|
||||||
currentOrder.repair_notes = payload.repair_notes;
|
if ('repair_notes' in payload) currentOrder.repair_notes = payload.repair_notes;
|
||||||
currentOrder.delivery_notes = payload.delivery_notes;
|
if ('delivery_notes' in payload) currentOrder.delivery_notes = payload.delivery_notes;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(e) { alert('Error: ' + e.message); });
|
.catch(function(e) { alert('Error: ' + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveMechanicName() {
|
||||||
|
if (!currentOrderId) return;
|
||||||
|
var input = document.getElementById('mechanicNameInput');
|
||||||
|
if (!input) return;
|
||||||
|
var payload = { mechanic_name: input.value.trim() || null };
|
||||||
|
api('PUT', '/' + currentOrderId, payload)
|
||||||
|
.then(function() {
|
||||||
|
alert('Mecánico asignado guardado');
|
||||||
|
if (currentOrder) currentOrder.mechanic_name = payload.mechanic_name;
|
||||||
|
})
|
||||||
|
.catch(function(e) { alert('Error: ' + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function reserveItem(itemId) {
|
function reserveItem(itemId) {
|
||||||
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
@@ -923,10 +1047,12 @@ var Workshop = (function() {
|
|||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var orderTier = currentOrder ? (currentOrder.customer_price_tier || 1) : 1;
|
||||||
box.innerHTML = items.map(function(it) {
|
box.innerHTML = items.map(function(it) {
|
||||||
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + (it.price_1 || 0) + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
|
var price = priceForTier(it.price_1, orderTier);
|
||||||
|
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + price + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
|
||||||
'<div><strong>' + esc(it.name) + '</strong></div>' +
|
'<div><strong>' + esc(it.name) + '</strong></div>' +
|
||||||
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '</small>' +
|
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(price)) + '</small>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
@@ -954,20 +1080,28 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function addSelectedItem() {
|
function addSelectedItem() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
|
var qty = parseFloat(document.getElementById('newItemQty').value) || 1;
|
||||||
|
var status = document.getElementById('newItemStatus').value || 'por_revisar';
|
||||||
|
var mechanicId = parseInt(document.getElementById('newItemMechanic').value, 10) || null;
|
||||||
|
var observations = document.getElementById('newItemObs').value.trim();
|
||||||
if (selectedInventoryItem) {
|
if (selectedInventoryItem) {
|
||||||
|
var price = hidePrices ? selectedInventoryItem.unit_price : (parseFloat(document.getElementById('newItemPrice').value) || selectedInventoryItem.unit_price || 0);
|
||||||
api('POST', '/' + currentOrderId + '/items', {
|
api('POST', '/' + currentOrderId + '/items', {
|
||||||
inventory_id: selectedInventoryItem.id,
|
inventory_id: selectedInventoryItem.id,
|
||||||
part_number: selectedInventoryItem.part_number,
|
part_number: selectedInventoryItem.part_number,
|
||||||
name: selectedInventoryItem.name,
|
name: selectedInventoryItem.name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_cost: selectedInventoryItem.unit_cost,
|
unit_cost: selectedInventoryItem.unit_cost,
|
||||||
unit_price: selectedInventoryItem.unit_price,
|
unit_price: price,
|
||||||
status: 'pending'
|
status: status,
|
||||||
|
mechanic_id: mechanicId,
|
||||||
|
observations: observations
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
selectedInventoryItem = null;
|
selectedInventoryItem = null;
|
||||||
document.getElementById('newItemSearch').value = '';
|
document.getElementById('newItemSearch').value = '';
|
||||||
document.getElementById('newItemQty').value = '1';
|
document.getElementById('newItemQty').value = '1';
|
||||||
|
if (!hidePrices) document.getElementById('newItemPrice').value = '';
|
||||||
|
document.getElementById('newItemObs').value = '';
|
||||||
openDetail(currentOrderId);
|
openDetail(currentOrderId);
|
||||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
return;
|
return;
|
||||||
@@ -975,19 +1109,36 @@ var Workshop = (function() {
|
|||||||
// Fallback: manual placeholder (no inventory link)
|
// Fallback: manual placeholder (no inventory link)
|
||||||
var name = document.getElementById('newItemSearch').value.trim();
|
var name = document.getElementById('newItemSearch').value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
|
var manualPrice = hidePrices ? 0 : (parseFloat(document.getElementById('newItemPrice').value) || 0);
|
||||||
api('POST', '/' + currentOrderId + '/items', {
|
api('POST', '/' + currentOrderId + '/items', {
|
||||||
name: name,
|
name: name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: 0,
|
unit_price: manualPrice,
|
||||||
status: 'pending'
|
status: status,
|
||||||
|
mechanic_id: mechanicId,
|
||||||
|
observations: observations
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
document.getElementById('newItemSearch').value = '';
|
document.getElementById('newItemSearch').value = '';
|
||||||
document.getElementById('newItemQty').value = '1';
|
document.getElementById('newItemQty').value = '1';
|
||||||
|
if (!hidePrices) document.getElementById('newItemPrice').value = '';
|
||||||
|
document.getElementById('newItemObs').value = '';
|
||||||
openDetail(currentOrderId);
|
openDetail(currentOrderId);
|
||||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editItemInline(itemId) {
|
||||||
|
if (!currentOrder || !currentOrder.items) return;
|
||||||
|
var it = currentOrder.items.find(function(x) { return x.id === itemId; });
|
||||||
|
if (!it) return;
|
||||||
|
var newStatus = prompt('Nuevo estado (' + Object.keys(ITEM_STATUS_LABELS).join(', ') + '):', it.status);
|
||||||
|
if (!newStatus || !ITEM_STATUS_LABELS[newStatus]) return;
|
||||||
|
var newObs = prompt('Observaciones:', it.observations || '');
|
||||||
|
var payload = {status: newStatus, observations: newObs != null ? newObs : it.observations};
|
||||||
|
api('PUT', '/items/' + itemId, payload)
|
||||||
|
.then(function() { openDetail(currentOrderId); })
|
||||||
|
.catch(function(e) { alert('Error: ' + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function addLabor() {
|
function addLabor() {
|
||||||
var desc = document.getElementById('laborDesc').value.trim();
|
var desc = document.getElementById('laborDesc').value.trim();
|
||||||
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
||||||
@@ -1020,6 +1171,18 @@ var Workshop = (function() {
|
|||||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertToRemission() {
|
||||||
|
if (!currentOrderId) return;
|
||||||
|
if (!confirm('¿Generar nota de remisión desde esta orden? Se reservarán las refacciones y quedará pendiente de cobro.')) return;
|
||||||
|
api('POST', '/' + currentOrderId + '/convert-to-remission', {})
|
||||||
|
.then(function(r) {
|
||||||
|
alert('Nota de remisión creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
|
||||||
|
closeDetailModal();
|
||||||
|
loadSummary();
|
||||||
|
loadOrders();
|
||||||
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function deleteOrder() {
|
function deleteOrder() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
|
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
|
||||||
@@ -1058,10 +1221,22 @@ var Workshop = (function() {
|
|||||||
// ─── New order ───
|
// ─── New order ───
|
||||||
|
|
||||||
function openNewOrderModal() {
|
function openNewOrderModal() {
|
||||||
|
populateSelect('noBranch', branches, function(b) { return {value: b.id, text: b.name}; });
|
||||||
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
||||||
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
|
|
||||||
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
|
|
||||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||||
|
populateMechanicSelect('noMechanic', null);
|
||||||
|
document.getElementById('noMechanicName').value = '';
|
||||||
|
document.getElementById('noEstimatedCost').value = '';
|
||||||
|
var noCustomer = document.getElementById('noCustomer');
|
||||||
|
noCustomer.onchange = function() {
|
||||||
|
var cid = parseInt(noCustomer.value, 10);
|
||||||
|
var c = customers.find(function(x) { return x.id === cid; });
|
||||||
|
if (c) {
|
||||||
|
document.getElementById('noWorkshopName').value = c.name || '';
|
||||||
|
document.getElementById('noCustomerPhone').value = c.phone || '';
|
||||||
|
document.getElementById('noCustomerAddress').value = c.address || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
document.getElementById('newOrderModal').classList.add('is-open');
|
document.getElementById('newOrderModal').classList.add('is-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,19 +1249,27 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
function submitNewOrder() {
|
function submitNewOrder() {
|
||||||
var customerId = document.getElementById('noCustomer').value;
|
var customerId = document.getElementById('noCustomer').value;
|
||||||
|
var branchId = document.getElementById('noBranch').value;
|
||||||
if (!customerId) return alert('Selecciona un cliente');
|
if (!customerId) return alert('Selecciona un cliente');
|
||||||
|
if (!branchId) return alert('Selecciona una sucursal');
|
||||||
var delivery = document.getElementById('noDelivery').value;
|
var delivery = document.getElementById('noDelivery').value;
|
||||||
|
var mechanicVal = document.getElementById('noMechanic').value;
|
||||||
|
var estimatedCostVal = document.getElementById('noEstimatedCost').value;
|
||||||
var payload = {
|
var payload = {
|
||||||
|
branch_id: parseInt(branchId, 10),
|
||||||
customer_id: parseInt(customerId, 10),
|
customer_id: parseInt(customerId, 10),
|
||||||
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
|
workshop_name: document.getElementById('noWorkshopName').value.trim() || null,
|
||||||
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
|
customer_address: document.getElementById('noCustomerAddress').value.trim() || null,
|
||||||
priority: document.getElementById('noPriority').value,
|
customer_phone: document.getElementById('noCustomerPhone').value.trim() || null,
|
||||||
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
|
vehicle_description: document.getElementById('noVehicleDescription').value.trim() || null,
|
||||||
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
|
|
||||||
reception_notes: document.getElementById('noNotes').value,
|
reception_notes: document.getElementById('noNotes').value,
|
||||||
delivery_method: delivery || null,
|
delivery_method: delivery || null,
|
||||||
courier_id: delivery === 'courier' ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
||||||
is_direct: document.getElementById('noDirect').checked
|
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
|
||||||
|
mechanic_name: document.getElementById('noMechanicName').value.trim() || null,
|
||||||
|
estimated_cost: estimatedCostVal ? parseFloat(estimatedCostVal) : null,
|
||||||
|
is_direct: document.getElementById('noDirect').checked,
|
||||||
|
requires_invoice: document.getElementById('noRequiresInvoice').checked
|
||||||
};
|
};
|
||||||
api('POST', '', payload).then(function() {
|
api('POST', '', payload).then(function() {
|
||||||
closeNewOrderModal();
|
closeNewOrderModal();
|
||||||
@@ -1168,18 +1351,13 @@ var Workshop = (function() {
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) { customers = (d.data || d.customers || []); })
|
.then(function(d) { customers = (d.data || d.customers || []); })
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
// Vehicles
|
|
||||||
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) { vehicles = (d.data || []); })
|
|
||||||
.catch(function() { vehicles = []; });
|
|
||||||
// Employees
|
// Employees
|
||||||
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) { employees = (d.data || d.employees || []); })
|
.then(function(d) { employees = (d.data || d.employees || []); })
|
||||||
.catch(function() { employees = []; });
|
.catch(function() { employees = []; });
|
||||||
// Couriers
|
// Couriers
|
||||||
fetch('/pos/api/couriers?per_page=500', {headers: headers()})
|
fetch('/pos/api/logistics/couriers?per_page=500', {headers: headers()})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
couriers = (d.data || d.couriers || []);
|
couriers = (d.data || d.couriers || []);
|
||||||
@@ -1192,6 +1370,11 @@ var Workshop = (function() {
|
|||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
branches = (d.data || []);
|
branches = (d.data || []);
|
||||||
populateBranchFilter();
|
populateBranchFilter();
|
||||||
|
var noBranch = document.getElementById('noBranch');
|
||||||
|
var userBranch = (window.POS_USER || {}).branch_id;
|
||||||
|
if (noBranch && userBranch && branches.some(function(b) { return b.id == userBranch; })) {
|
||||||
|
noBranch.value = userBranch;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { branches = []; });
|
.catch(function() { branches = []; });
|
||||||
}
|
}
|
||||||
@@ -1235,11 +1418,14 @@ var Workshop = (function() {
|
|||||||
changeStatus: changeStatus,
|
changeStatus: changeStatus,
|
||||||
reserveItem: reserveItem,
|
reserveItem: reserveItem,
|
||||||
saveNotes: saveNotes,
|
saveNotes: saveNotes,
|
||||||
|
saveMechanicName: saveMechanicName,
|
||||||
searchItemsForSO: searchItemsForSO,
|
searchItemsForSO: searchItemsForSO,
|
||||||
selectInventoryItem: selectInventoryItem,
|
selectInventoryItem: selectInventoryItem,
|
||||||
addSelectedItem: addSelectedItem,
|
addSelectedItem: addSelectedItem,
|
||||||
|
editItemInline: editItemInline,
|
||||||
addLabor: addLabor,
|
addLabor: addLabor,
|
||||||
convertToSale: convertToSale,
|
convertToSale: convertToSale,
|
||||||
|
convertToRemission: convertToRemission,
|
||||||
deleteOrder: deleteOrder,
|
deleteOrder: deleteOrder,
|
||||||
printOrder: printOrder,
|
printOrder: printOrder,
|
||||||
openNewOrderModal: openNewOrderModal,
|
openNewOrderModal: openNewOrderModal,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||||
// so templates can use cache-busting query params freely.
|
// so templates can use cache-busting query params freely.
|
||||||
|
|
||||||
const VERSION = 33;
|
const VERSION = 40;
|
||||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
|
|||||||
@@ -492,11 +492,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
|
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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>
|
||||||
|
|||||||
@@ -314,12 +314,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/catalog.js?v=33" defer></script>
|
<script src="/pos/static/js/catalog.js?v=33" 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/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
|
|||||||
@@ -15,7 +15,39 @@
|
|||||||
<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/config.css?v=33"></head>
|
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
|
||||||
|
<style>
|
||||||
|
.cfg-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cfg-tab-btn {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.cfg-tab-btn:hover { background: var(--color-surface-3); }
|
||||||
|
.cfg-tab-btn.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.settings-section[data-tab] { display: none !important; }
|
||||||
|
.settings-section[data-tab].active { display: block !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -138,11 +170,20 @@
|
|||||||
|
|
||||||
<!-- Scrollable Content -->
|
<!-- Scrollable Content -->
|
||||||
<div class="content-scroll">
|
<div class="content-scroll">
|
||||||
|
<!-- Config tabs -->
|
||||||
|
<div class="cfg-tabs" id="configTabs">
|
||||||
|
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
|
||||||
|
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
|
||||||
|
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
|
||||||
|
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
|
||||||
|
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 1: APARIENCIA / TEMA
|
SECTION 1: APARIENCIA / TEMA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
@@ -205,7 +246,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 2: DATOS DE LA EMPRESA
|
SECTION 2: DATOS DE LA EMPRESA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
@@ -256,7 +297,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 3: PERSONALIZACIÓN DE TICKET
|
SECTION 3: PERSONALIZACIÓN DE TICKET
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
@@ -328,7 +369,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 4: MÓDULOS E INTEGRACIONES
|
SECTION 4: MÓDULOS E INTEGRACIONES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
@@ -380,6 +421,16 @@
|
|||||||
<span class="toggle__slider"></span>
|
<span class="toggle__slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-row__info">
|
||||||
|
<span class="toggle-row__label">Notas de remisión en mostrador</span>
|
||||||
|
<span class="toggle-row__desc">Permite generar notas de remisión desde el POS para cobrar posteriormente en caja</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-module-counter-remission" />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div style="margin-top:var(--space-4);text-align:right;">
|
<div style="margin-top:var(--space-4);text-align:right;">
|
||||||
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,13 +440,13 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 4: USUARIOS Y PERMISOS
|
SECTION 4: USUARIOS Y PERMISOS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="employees">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-section__title">Usuarios y Permisos</div>
|
<div class="settings-section__title">Empleados</div>
|
||||||
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +483,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 4: IMPRESORAS
|
SECTION 4: IMPRESORAS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||||
@@ -458,7 +509,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 5: SUCURSALES
|
SECTION 5: SUCURSALES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="fiscal">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
@@ -477,7 +528,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 6: PARÁMETROS FISCALES
|
SECTION 6: PARÁMETROS FISCALES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="fiscal">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
@@ -613,7 +664,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 7: PREFERENCIAS DEL SISTEMA
|
SECTION 7: PREFERENCIAS DEL SISTEMA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="general">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
@@ -691,7 +742,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 8: VEHICLE COMPATIBILITY SOURCE
|
SECTION 8: VEHICLE COMPATIBILITY SOURCE
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="catalog">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
@@ -724,7 +775,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" data-tab="catalog">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<div class="settings-section__icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
|
||||||
@@ -785,6 +836,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ===============================================================
|
||||||
|
SECTION: PERMISOS POR ROL
|
||||||
|
=============================================================== -->
|
||||||
|
<div class="settings-section" data-tab="permissions">
|
||||||
|
<div class="settings-section__header">
|
||||||
|
<div class="settings-section__icon">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="settings-section__title">Permisos por Rol</div>
|
||||||
|
<div class="settings-section__desc">Define los permisos predeterminados para cada rol del sistema</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="form-group" style="max-width:320px;">
|
||||||
|
<label class="form-label">Rol</label>
|
||||||
|
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions()">
|
||||||
|
<option value="">Selecciona un rol</option>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
<option value="cashier">Cajero</option>
|
||||||
|
<option value="counter">Mostrador</option>
|
||||||
|
<option value="warehouse">Almacén</option>
|
||||||
|
<option value="accountant">Contador</option>
|
||||||
|
<option value="workshop">Taller</option>
|
||||||
|
<option value="mechanic">Mecánico</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="role-permissions-container" style="margin-top:var(--space-4);">
|
||||||
|
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:var(--space-4);">
|
||||||
|
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
|
||||||
|
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /content-scroll -->
|
</div><!-- /content-scroll -->
|
||||||
</main>
|
</main>
|
||||||
</div><!-- /app-shell -->
|
</div><!-- /app-shell -->
|
||||||
@@ -893,6 +985,7 @@
|
|||||||
<option value="">-- Seleccionar --</option>
|
<option value="">-- Seleccionar --</option>
|
||||||
<option value="admin">Administrador</option>
|
<option value="admin">Administrador</option>
|
||||||
<option value="cashier">Cajero</option>
|
<option value="cashier">Cajero</option>
|
||||||
|
<option value="counter">Mostrador</option>
|
||||||
<option value="warehouse">Almacenista</option>
|
<option value="warehouse">Almacenista</option>
|
||||||
<option value="accountant">Contador</option>
|
<option value="accountant">Contador</option>
|
||||||
<option value="workshop">Taller</option>
|
<option value="workshop">Taller</option>
|
||||||
@@ -922,13 +1015,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/config.js?v=35" defer></script>
|
<script src="/pos/static/js/config.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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>
|
||||||
|
|||||||
@@ -650,11 +650,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||||
<script src="/pos/static/js/customers.js?v=34" defer></script>
|
<script src="/pos/static/js/customers.js?v=34" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
|
|||||||
@@ -158,6 +158,16 @@
|
|||||||
Ventas Históricas
|
Ventas Históricas
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/pos/remission-notes" class="nav-link">
|
||||||
|
<span class="nav-link__icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M2 3h12v10H2z"/>
|
||||||
|
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Notas de Remisión
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="sidebar__section-label">Gestión</div>
|
<div class="sidebar__section-label">Gestión</div>
|
||||||
|
|
||||||
<a href="/pos/marketplace" class="nav-link">
|
<a href="/pos/marketplace" class="nav-link">
|
||||||
@@ -595,15 +605,15 @@
|
|||||||
|
|
||||||
|
|
||||||
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
|
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/dashboard.js?v=33" defer></script>
|
<script src="/pos/static/js/dashboard.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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?v=34',{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>
|
||||||
|
|||||||
@@ -148,12 +148,12 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/diagrams.js" defer></script>
|
<script src="/pos/static/js/diagrams.js" 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>
|
||||||
|
|||||||
@@ -302,11 +302,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/fleet.js" defer></script>
|
<script src="/pos/static/js/fleet.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" 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>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
<h1 class="page-header__title">Inventario</h1>
|
<h1 class="page-header__title">Inventario</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
Importar CSV
|
Importar CSV
|
||||||
</button>
|
</button>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
|
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
|
||||||
Sincronizar
|
Sincronizar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--primary" onclick="showCreateModal()">
|
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Nuevo Producto
|
Nuevo Producto
|
||||||
</button>
|
</button>
|
||||||
@@ -336,7 +336,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
||||||
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Nuevo Producto
|
Nuevo Producto
|
||||||
</button>
|
</button>
|
||||||
@@ -1018,6 +1018,9 @@
|
|||||||
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</button>
|
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-modal__body">
|
<div class="inv-modal__body">
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<button class="btn btn--ghost btn--sm" onclick="downloadBulkImportTemplate()" type="button">📥 Descargar plantilla CSV</button>
|
||||||
|
</div>
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
|
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
|
||||||
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
|
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
|
||||||
@@ -1039,8 +1042,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
|
||||||
<strong>Columnas esperadas:</strong>
|
<strong>Columnas esperadas:</strong>
|
||||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
|
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
|
||||||
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1058,13 +1061,13 @@
|
|||||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
|
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/inventory.js?v=34" defer></script>
|
<script src="/pos/static/js/inventory.js?v=37" 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/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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>
|
||||||
|
|||||||
@@ -1063,11 +1063,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/invoicing.js?v=33" defer></script>
|
<script src="/pos/static/js/invoicing.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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>
|
||||||
|
|||||||
@@ -387,7 +387,7 @@
|
|||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
var role = (result.data.employee.role || '').toLowerCase();
|
var role = (result.data.employee.role || '').toLowerCase();
|
||||||
if (role === 'workshop' || role === 'mechanic') {
|
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||||
window.location.href = '/pos/workshop';
|
window.location.href = '/pos/workshop';
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/pos/catalog';
|
window.location.href = '/pos/catalog';
|
||||||
|
|||||||
@@ -342,11 +342,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
|
<script src="/pos/static/js/marketplace_external.js?v=33" 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>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<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" />
|
||||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=33"></head>
|
<link rel="stylesheet" href="/pos/static/css/pos.css?v=34"></head>
|
||||||
|
|
||||||
<body class="pos-shell" id="appBody">
|
<body class="pos-shell" id="appBody">
|
||||||
|
|
||||||
@@ -123,7 +123,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-change-customer" onclick="POS.clearCustomer()" aria-label="Cambiar cliente">Cambiar</button>
|
<button class="btn-change-customer" onclick="POS.clearCustomer()" aria-label="Cambiar cliente">Cambiar</button>
|
||||||
<button class="btn-change-customer" onclick="POS.creditSale()" aria-label="Venta a credito" style="margin-left:4px;background:var(--color-primary);color:#fff;border-color:var(--color-primary);">Crédito</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,15 +198,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Courier selector for counter remission notes -->
|
||||||
|
<div class="form-field" id="courierSelectField" style="display:none; margin-bottom: 12px;">
|
||||||
|
<label class="form-label" for="remissionCourier">Repartidor</label>
|
||||||
|
<select class="form-input" id="remissionCourier">
|
||||||
|
<option value="">-- Sin repartidor --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- COBRAR Button -->
|
<!-- COBRAR Button -->
|
||||||
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
|
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
|
||||||
<span>COBRAR</span>
|
<span>COBRAR</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Counter remission button (shown for counter role when feature enabled) -->
|
||||||
|
<button class="btn-cobrar" id="btnRemission" onclick="POS.createRemissionNote()" aria-label="Generar nota de remision" style="display:none;background:var(--color-secondary);">
|
||||||
|
<span>NOTA DE REMISIÓN</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Secondary Actions -->
|
<!-- Secondary Actions -->
|
||||||
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
||||||
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
|
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
||||||
|
<button class="btn-secondary-action" id="btnPayRemission" onclick="POS.openPayRemissionModal()" title="Cobrar nota de remision" style="display:none;">Cobrar Nota</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
|
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
|
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
||||||
@@ -295,6 +308,15 @@
|
|||||||
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
||||||
Mixto
|
Mixto
|
||||||
</button>
|
</button>
|
||||||
|
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
|
||||||
|
Crédito
|
||||||
|
</button>
|
||||||
|
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
|
||||||
|
Cheque
|
||||||
|
</button>
|
||||||
|
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
|
||||||
|
Pendiente
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB: Efectivo -->
|
<!-- TAB: Efectivo -->
|
||||||
@@ -358,6 +380,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB: Crédito -->
|
||||||
|
<div class="tab-content" id="creditPayment">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Venta a crédito</label>
|
||||||
|
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB: Cheque -->
|
||||||
|
<div class="tab-content" id="chequePayment">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Monto</label>
|
||||||
|
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">No. de cheque / referencia</label>
|
||||||
|
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB: Pendiente -->
|
||||||
|
<div class="tab-content" id="pendingPayment">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Pago pendiente</label>
|
||||||
|
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- CFDI Checkbox -->
|
<!-- CFDI Checkbox -->
|
||||||
<div class="cfdi-check">
|
<div class="cfdi-check">
|
||||||
<input type="checkbox" id="cfdiCheck" />
|
<input type="checkbox" id="cfdiCheck" />
|
||||||
@@ -373,6 +424,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
PAY REMISSION NOTE MODAL
|
||||||
|
================================================================ -->
|
||||||
|
<div class="modal-overlay" id="payRemissionModal">
|
||||||
|
<div class="modal-pago">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Cobrar Nota de Remisión</h3>
|
||||||
|
<button class="modal-close" onclick="POS.closePayRemissionModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" style="padding: 16px; gap: 8px;">
|
||||||
|
<input class="form-input" type="number" id="payRemissionFolio" placeholder="Folio de la nota (NR-XXXX)" style="flex:1;" />
|
||||||
|
<button class="btn btn-primary" onclick="POS.searchRemissionToPay()">Buscar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="payRemissionDetail" style="padding: 0 16px 16px; max-height: 220px; overflow-y: auto;"></div>
|
||||||
|
|
||||||
|
<div id="payRemissionActions" style="display:none; padding: 0 16px 16px;">
|
||||||
|
<div class="form-row" style="gap: 8px; margin-bottom: 8px;">
|
||||||
|
<select class="form-input" id="payRemissionMethod" onchange="POS.updatePayRemissionMethod()">
|
||||||
|
<option value="efectivo">Efectivo</option>
|
||||||
|
<option value="transferencia">Transferencia</option>
|
||||||
|
<option value="tarjeta">Tarjeta</option>
|
||||||
|
<option value="mixto">Mixto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="payRemissionCash" class="form-row" style="gap: 8px; margin-bottom: 8px;">
|
||||||
|
<input class="form-input" type="number" id="payRemissionReceived" placeholder="Recibido" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div id="payRemissionRef" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none;">
|
||||||
|
<input class="form-input" type="text" id="payRemissionReference" placeholder="Referencia" />
|
||||||
|
</div>
|
||||||
|
<div id="payRemissionMixed" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none; flex-direction: column;">
|
||||||
|
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
|
||||||
|
<select class="form-input" style="flex:1;">
|
||||||
|
<option value="efectivo">Efectivo</option>
|
||||||
|
<option value="transferencia">Transferencia</option>
|
||||||
|
<option value="tarjeta">Tarjeta</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
|
||||||
|
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
|
||||||
|
</div>
|
||||||
|
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
|
||||||
|
<select class="form-input" style="flex:1;">
|
||||||
|
<option value="efectivo">Efectivo</option>
|
||||||
|
<option value="transferencia">Transferencia</option>
|
||||||
|
<option value="tarjeta">Tarjeta</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
|
||||||
|
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding:0;">
|
||||||
|
<button class="btn btn-ghost" onclick="POS.closePayRemissionModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" id="btnConfirmPayRemission" onclick="POS.confirmPayRemission()">Confirmar Pago</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="payRemissionResult" style="padding: 0 16px 16px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
CANCEL SALE CONFIRMATION MODAL
|
CANCEL SALE CONFIRMATION MODAL
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
@@ -613,14 +726,14 @@
|
|||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
JAVASCRIPT
|
JAVASCRIPT
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<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/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/push.js" defer></script>
|
<script src="/pos/static/js/push.js" defer></script>
|
||||||
<script src="/pos/static/js/printer.js" defer></script>
|
<script src="/pos/static/js/printer.js" defer></script>
|
||||||
<script src="/pos/static/js/pos.js?v=33" defer></script>
|
<script src="/pos/static/js/pos.js?v=41" defer></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Cancel sale button wiring
|
// Cancel sale button wiring
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
|
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
|
||||||
<body>
|
<body>
|
||||||
<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/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1 class="page-title">Cotizaciones</h1>
|
<h1 class="page-title">Cotizaciones</h1>
|
||||||
|
|||||||
436
pos/templates/remission_notes.html
Normal file
436
pos/templates/remission_notes.html
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Notas de Remisión — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
|
||||||
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- THEME BAR -->
|
||||||
|
<header class="theme-bar" role="banner">
|
||||||
|
<div class="theme-bar__left">
|
||||||
|
<div class="theme-bar__store">
|
||||||
|
<span class="theme-bar__dot"></span>
|
||||||
|
Nexus Autoparts
|
||||||
|
</div>
|
||||||
|
<div class="theme-bar__sep"></div>
|
||||||
|
<span class="theme-bar__label">Sucursal Centro — Usuario: H. García</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-bar__right">
|
||||||
|
<span class="theme-bar__label">Tema:</span>
|
||||||
|
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
|
||||||
|
<span class="theme-btn__swatch"></span>
|
||||||
|
Industrial
|
||||||
|
</button>
|
||||||
|
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
|
||||||
|
<span class="theme-btn__swatch"></span>
|
||||||
|
Moderno
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- APP SHELL -->
|
||||||
|
<div class="app-shell">
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
|
||||||
|
<div class="sidebar__brand">
|
||||||
|
<div class="brand-logo">NA</div>
|
||||||
|
<div class="brand-name">
|
||||||
|
<span class="brand-name__primary">Nexus</span>
|
||||||
|
<span class="brand-name__sub">Autoparts POS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar__nav">
|
||||||
|
<div class="nav-section-label">Principal</div>
|
||||||
|
<a class="nav-item" href="/pos/dashboard">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" href="/pos/sale">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||||
|
</svg>
|
||||||
|
<span>POS</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" href="/pos/catalog">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
<span>Catálogo</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" href="/pos/inventory">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>Inventario</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Gestión</div>
|
||||||
|
<a class="nav-item" href="/pos/customers">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
<span>Clientes</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item is-active" href="/pos/remission-notes" aria-current="page">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>Notas de Remisión</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" href="/pos/invoicing">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span>Facturación</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" href="/pos/reports">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
<span>Reportes</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Sistema</div>
|
||||||
|
<a class="nav-item" href="/pos/config">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
<span>Configuración</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
<div class="sidebar__user-avatar">HG</div>
|
||||||
|
<div class="sidebar__user-info">
|
||||||
|
<div class="sidebar__user-name">Hugo García</div>
|
||||||
|
<div class="sidebar__user-role">Administrador</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<main class="main" role="main">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header__title-group">
|
||||||
|
<span class="page-header__eyebrow">Ventas</span>
|
||||||
|
<h1 class="page-header__title">Notas de Remisión</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="filterFolio" placeholder="Buscar folio NR-XXXX..." />
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="filterCustomer" placeholder="Cliente..." />
|
||||||
|
</div>
|
||||||
|
<select class="select-filter" id="filterStatus">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="pending_payment" selected>Pendientes</option>
|
||||||
|
<option value="completed">Pagadas</option>
|
||||||
|
<option value="cancelled">Canceladas</option>
|
||||||
|
</select>
|
||||||
|
<select class="select-filter" id="filterCourier">
|
||||||
|
<option value="">Todos los repartidores</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" class="select-filter" id="dateFrom" />
|
||||||
|
<input type="date" class="select-filter" id="dateTo" />
|
||||||
|
<div class="toolbar__spacer"></div>
|
||||||
|
<button class="btn btn--primary" onclick="loadData(1)">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table" id="remissionTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Folio</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Vendedor</th>
|
||||||
|
<th>Repartidor</th>
|
||||||
|
<th style="text-align:right">Total</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th style="text-align:right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="remissionTableBody">
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:var(--space-8);">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="table-footer">
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Modal -->
|
||||||
|
<div class="modal-overlay" id="ticketModal" onclick="if(event.target===this) closeTicketModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h3 class="modal__title">Vista previa</h3>
|
||||||
|
<button class="modal__close" onclick="closeTicketModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<div id="ticketContent" class="ticket-preview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button class="btn btn--ghost" onclick="closeTicketModal()">Cerrar</button>
|
||||||
|
<button class="btn btn--primary" onclick="printTicket()">
|
||||||
|
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
|
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const token = localStorage.getItem('pos_token') || '';
|
||||||
|
let currentPage = 1;
|
||||||
|
let couriers = [];
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
options.headers = headers();
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return { pending_payment: 'Pendiente', completed: 'Pagada', cancelled: 'Cancelada' }[status] || status;
|
||||||
|
}
|
||||||
|
function statusClass(status) {
|
||||||
|
return { pending_payment: 'badge--pending_payment', completed: 'badge--completed', cancelled: 'badge--cancelled' }[status] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCouriers() {
|
||||||
|
try {
|
||||||
|
const data = await api('/pos/api/logistics/couriers');
|
||||||
|
couriers = data.couriers || [];
|
||||||
|
const sel = document.getElementById('filterCourier');
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">Todos los repartidores</option>' + couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||||
|
sel.value = current;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('No se pudieron cargar repartidores', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(page) {
|
||||||
|
currentPage = page;
|
||||||
|
const tbody = document.getElementById('remissionTableBody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Cargando...</td></tr>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('sale_type', 'counter_remission');
|
||||||
|
params.set('per_page', '50');
|
||||||
|
params.set('page', String(page));
|
||||||
|
|
||||||
|
const status = document.getElementById('filterStatus').value;
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const folio = document.getElementById('filterFolio').value.trim();
|
||||||
|
if (folio) params.set('q', folio.replace(/^NR-/i, ''));
|
||||||
|
const customer = document.getElementById('filterCustomer').value.trim();
|
||||||
|
if (customer) params.set('customer', customer);
|
||||||
|
const courier = document.getElementById('filterCourier').value;
|
||||||
|
if (courier) params.set('courier_id', courier);
|
||||||
|
const from = document.getElementById('dateFrom').value;
|
||||||
|
const to = document.getElementById('dateTo').value;
|
||||||
|
if (from) params.set('date_from', from);
|
||||||
|
if (to) params.set('date_to', to);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/pos/api/sales?' + params.toString());
|
||||||
|
render(res.data || [], res.pagination || {});
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-error);">Error: ${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows, pagination) {
|
||||||
|
const tbody = document.getElementById('remissionTableBody');
|
||||||
|
const pag = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="8">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__title">No hay notas de remisión</div>
|
||||||
|
<div class="empty-state__subtitle">Ajusta los filtros o genera una nueva nota desde el POS.</div>
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
|
pag.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = rows.map(r => {
|
||||||
|
const canPay = r.status === 'pending_payment';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="td--mono">NR-${r.id}</td>
|
||||||
|
<td>${new Date(r.created_at).toLocaleString('es-MX')}</td>
|
||||||
|
<td class="td--primary">${r.customer_name || 'Público General'}</td>
|
||||||
|
<td>${r.employee_name || '-'}</td>
|
||||||
|
<td>${r.courier_name || '-'}</td>
|
||||||
|
<td class="td--amount" style="text-align:right">${fmt(r.total)}</td>
|
||||||
|
<td><span class="badge ${statusClass(r.status)}">${statusLabel(r.status)}</span></td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:var(--space-2);">
|
||||||
|
<button class="action-btn action-btn--ghost" onclick="viewTicket(${r.id})" title="Imprimir">
|
||||||
|
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||||
|
</button>
|
||||||
|
${canPay ? `<button class="action-btn action-btn--primary" onclick="payNote(${r.id}, ${r.total})" title="Cobrar">
|
||||||
|
<svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M6 14h.01"/></svg>
|
||||||
|
</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (pagination.total_pages > 1) {
|
||||||
|
pag.innerHTML = `
|
||||||
|
<button class="page-btn" onclick="loadData(${pagination.page - 1})" ${pagination.page <= 1 ? 'disabled' : ''}>←</button>
|
||||||
|
<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Página ${pagination.page} de ${pagination.total_pages}</span>
|
||||||
|
<button class="page-btn" onclick="loadData(${pagination.page + 1})" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>→</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
pag.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewTicket(saleId) {
|
||||||
|
try {
|
||||||
|
const data = await api('/pos/api/sales/' + saleId + '/print-remission', { method: 'POST', body: '{}' });
|
||||||
|
const dateStr = new Date(data.date).toLocaleString('es-MX');
|
||||||
|
const itemsHtml = (data.items || []).map(it => `
|
||||||
|
<div class="ticket-preview__item"><span>${it.quantity} x ${it.name}</span><span>${fmt(it.subtotal)}</span></div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('ticketContent').innerHTML = `
|
||||||
|
<div class="ticket-preview__center ticket-preview__bold">${data.business_name || 'NEXUS AUTOPARTS'}</div>
|
||||||
|
<div class="ticket-preview__center">${data.business_rfc || ''}</div>
|
||||||
|
<div class="ticket-preview__center">${data.business_address || ''}</div>
|
||||||
|
<div class="ticket-preview__divider"></div>
|
||||||
|
<div class="ticket-preview__center ticket-preview__bold">NOTA DE REMISIÓN</div>
|
||||||
|
<div class="ticket-preview__center">${data.folio}</div>
|
||||||
|
<div class="ticket-preview__center">${dateStr}</div>
|
||||||
|
<div class="ticket-preview__divider"></div>
|
||||||
|
<div class="ticket-preview__line"><span>Cliente:</span><span>${data.customer || 'Público General'}</span></div>
|
||||||
|
<div class="ticket-preview__line"><span>Vendedor:</span><span>${data.employee || '-'}</span></div>
|
||||||
|
${data.courier ? `<div class="ticket-preview__line"><span>Repartidor:</span><span>${data.courier}</span></div>` : ''}
|
||||||
|
<div class="ticket-preview__items">${itemsHtml}</div>
|
||||||
|
<div class="ticket-preview__divider"></div>
|
||||||
|
<div class="ticket-preview__line"><span>Subtotal:</span><span>${fmt(data.subtotal)}</span></div>
|
||||||
|
${data.discount_total ? `<div class="ticket-preview__line"><span>Descuento:</span><span>-${fmt(data.discount_total)}</span></div>` : ''}
|
||||||
|
<div class="ticket-preview__line"><span>IVA:</span><span>${fmt(data.tax_total)}</span></div>
|
||||||
|
<div class="ticket-preview__line ticket-preview__bold"><span>TOTAL:</span><span>${fmt(data.total)}</span></div>
|
||||||
|
<div class="ticket-preview__divider"></div>
|
||||||
|
<div class="ticket-preview__footer ticket-preview__bold">PENDIENTE DE PAGO</div>
|
||||||
|
<div class="ticket-preview__footer" style="font-size:0.75rem;opacity:0.8;">Presente esta nota en caja para pagar</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('ticketModal').classList.add('is-open');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTicketModal() {
|
||||||
|
document.getElementById('ticketModal').classList.remove('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTicket() {
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
w.document.write('<html><head><title>Nota de Remisión</title></head><body>' + document.getElementById('ticketContent').innerHTML + '</body></html>');
|
||||||
|
w.document.close();
|
||||||
|
w.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payNote(saleId, total) {
|
||||||
|
const method = prompt(`Cobrar nota NR-${saleId}\nTotal: ${fmt(total)}\n\nForma de pago: efectivo / transferencia / tarjeta`, 'efectivo');
|
||||||
|
if (!method) return;
|
||||||
|
const reference = ['transferencia', 'tarjeta'].includes(method) ? prompt('Referencia:') : '';
|
||||||
|
try {
|
||||||
|
await api('/pos/api/sales/' + saleId + '/pay', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
payment_method: method,
|
||||||
|
amount_paid: total,
|
||||||
|
reference: reference || ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
alert('Nota cobrada correctamente');
|
||||||
|
loadData(currentPage);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error al cobrar: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('remissionTableBody').innerHTML = `<tr><td colspan="8">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__title">Inicia sesión</div>
|
||||||
|
<div class="empty-state__subtitle">Se requiere autenticación para ver las notas de remisión.</div>
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadCouriers();
|
||||||
|
loadData(1);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -229,6 +229,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Histórico
|
Histórico
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('cortes', this)">
|
||||||
|
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<rect x="1" y="3" width="13" height="10" rx="1"/>
|
||||||
|
<path d="M4 7h7M4 10h5"/>
|
||||||
|
<circle cx="11" cy="10" r="1.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
Cortes de caja
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================================================================
|
<!-- ==================================================================
|
||||||
@@ -355,6 +363,38 @@
|
|||||||
<!-- Sales detail table -->
|
<!-- Sales detail table -->
|
||||||
<div class="table-card mb-5" id="historico-detalle"></div>
|
<div class="table-card mb-5" id="historico-detalle"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================================================================
|
||||||
|
TAB 6: CORTES DE CAJA
|
||||||
|
================================================================== -->
|
||||||
|
<div class="tab-panel" id="panel-cortes">
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<span class="filter-bar__label">Desde</span>
|
||||||
|
<input type="date" class="filter-input" id="cortes-date-from" />
|
||||||
|
<span class="filter-bar__label">Hasta</span>
|
||||||
|
<input type="date" class="filter-input" id="cortes-date-to" />
|
||||||
|
<span id="cortes-employee-filter">
|
||||||
|
<span class="filter-bar__label">Cajero</span>
|
||||||
|
<select class="filter-select" id="cortes-employee">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
<div class="filter-bar__spacer"></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="Reports.loadCortes()">Generar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards (dynamic) -->
|
||||||
|
<div class="kpi-grid" id="cortes-kpis"></div>
|
||||||
|
|
||||||
|
<!-- Cortes detail table -->
|
||||||
|
<div class="table-card mb-5" id="cortes-detalle"></div>
|
||||||
|
|
||||||
|
<!-- Detail of sales for the selected cash cut -->
|
||||||
|
<div class="table-card mb-5" id="corte-ventas-detalle" style="display:none;"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- End panels -->
|
<!-- End panels -->
|
||||||
|
|
||||||
@@ -365,12 +405,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- End app-shell -->
|
<!-- End app-shell -->
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
<script src="/pos/static/js/reports.js?v=33" defer></script>
|
<script src="/pos/static/js/reports.js?v=35" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" 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>
|
||||||
|
|||||||
@@ -126,8 +126,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||||
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script>
|
<script src="/pos/static/js/supplier_catalog.js?v=33" 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>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -131,12 +131,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script>
|
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script>
|
||||||
<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=44" defer></script>
|
||||||
|
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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=42">
|
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=44">
|
||||||
<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); }
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card__body">
|
<div class="summary-card__body">
|
||||||
<div class="summary-card__label">Recibidos</div>
|
<div class="summary-card__label">Por revisar</div>
|
||||||
<div class="summary-card__value" id="statReceived">--</div>
|
<div class="summary-card__value" id="statReceived">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card__body">
|
<div class="summary-card__body">
|
||||||
<div class="summary-card__label">Listos</div>
|
<div class="summary-card__label">Por entregar</div>
|
||||||
<div class="summary-card__value" id="statReady">--</div>
|
<div class="summary-card__value" id="statReady">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,20 +96,28 @@
|
|||||||
</select>
|
</select>
|
||||||
<select class="form-input" id="filterStatus">
|
<select class="form-input" id="filterStatus">
|
||||||
<option value="">Todos los estatus</option>
|
<option value="">Todos los estatus</option>
|
||||||
<option value="received">Recibido</option>
|
<option value="por_revisar">Por revisar</option>
|
||||||
<option value="diagnosis">Diagnóstico</option>
|
<option value="en_revision">En revisión</option>
|
||||||
<option value="waiting_parts">Espera refacciones</option>
|
<option value="revisada">Revisada</option>
|
||||||
<option value="repair">En reparación</option>
|
<option value="cotizada">Cotizada</option>
|
||||||
<option value="quality_check">Control calidad</option>
|
<option value="por_autorizar">Por autorizar</option>
|
||||||
<option value="ready">Listo</option>
|
<option value="autorizada">Autorizada</option>
|
||||||
<option value="delivered">Entregado</option>
|
<option value="autorizacion_parcial">Autorización parcial</option>
|
||||||
<option value="cancelled">Cancelado</option>
|
<option value="en_reparacion">En reparación</option>
|
||||||
|
<option value="reparada">Reparada</option>
|
||||||
|
<option value="por_entregar">Por entregar</option>
|
||||||
|
<option value="entregado">Entregado</option>
|
||||||
|
<option value="por_enviar">Por enviar</option>
|
||||||
|
<option value="enviado">Enviado</option>
|
||||||
|
<option value="por_facturar">Por facturar</option>
|
||||||
|
<option value="facturada">Facturada</option>
|
||||||
|
<option value="por_recolectar">Por recolectar</option>
|
||||||
|
<option value="cancelada">Cancelada</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="form-input" id="filterDelivery">
|
<select class="form-input" id="filterDelivery">
|
||||||
<option value="">Todas las vías de entrega</option>
|
<option value="">Todas las vías de entrega</option>
|
||||||
<option value="pickup">Pasa cliente</option>
|
<option value="pickup">Mostrador</option>
|
||||||
<option value="delivery">Envío a domicilio</option>
|
<option value="delivery">Envío a domicilio</option>
|
||||||
<option value="courier">Motociclista</option>
|
|
||||||
</select>
|
</select>
|
||||||
<label class="toolbar-toggle">
|
<label class="toolbar-toggle">
|
||||||
<input type="checkbox" id="filterDirect" />
|
<input type="checkbox" id="filterDirect" />
|
||||||
@@ -137,15 +145,16 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Sucursal</th>
|
<th>Sucursal</th>
|
||||||
<th>Orden</th>
|
<th>Orden</th>
|
||||||
<th>Cliente</th>
|
<th class="restricted-hide">Cliente</th>
|
||||||
<th>Vehículo</th>
|
<th class="restricted-hide">Taller</th>
|
||||||
|
<th class="restricted-hide">Vehículo</th>
|
||||||
<th>Estatus</th>
|
<th>Estatus</th>
|
||||||
<th class="price-col">Total</th>
|
<th class="price-col restricted-hide">Total</th>
|
||||||
<th>Acciones</th>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="listBody">
|
<tbody id="listBody">
|
||||||
<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
|
<tr><td colspan="8" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +193,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
<form id="newOrderForm" class="form-grid">
|
<form id="newOrderForm" class="form-grid">
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="noBranch">Sucursal</label>
|
||||||
|
<select class="form-input" id="noBranch" required></select>
|
||||||
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noCustomer">Cliente</label>
|
<label class="form-label" for="noCustomer">Cliente</label>
|
||||||
<div style="display:flex;gap:var(--space-2);">
|
<div style="display:flex;gap:var(--space-2);">
|
||||||
@@ -192,29 +205,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noVehicle">Vehículo</label>
|
<label class="form-label" for="noWorkshopName">Taller</label>
|
||||||
<div style="display:flex;gap:var(--space-2);">
|
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
|
||||||
<select class="form-input" id="noVehicle" style="flex:1;"></select>
|
|
||||||
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModalFromNewOrder()">+ Nuevo</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noMechanic">Mecánico asignado</label>
|
<label class="form-label" for="noCustomerPhone">Teléfono</label>
|
||||||
<select class="form-input" id="noMechanic"></select>
|
<input class="form-input" id="noCustomerPhone" placeholder="3312345678" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field form-field--span2">
|
||||||
|
<label class="form-label" for="noCustomerAddress">Dirección</label>
|
||||||
|
<input class="form-input" id="noCustomerAddress" placeholder="Calle, número, colonia" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noPriority">Prioridad</label>
|
<label class="form-label" for="noVehicleDescription">Vehículo</label>
|
||||||
<select class="form-input" id="noPriority">
|
<input class="form-input" id="noVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||||
<option value="normal">Normal</option>
|
|
||||||
<option value="high">Alta</option>
|
|
||||||
<option value="urgent">Urgente</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noDelivery">Vía de entrega</label>
|
<label class="form-label" for="noDelivery">Vía de entrega</label>
|
||||||
<select class="form-input" id="noDelivery">
|
<select class="form-input" id="noDelivery">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
<option value="pickup">Pasa cliente</option>
|
<option value="pickup">Mostrador</option>
|
||||||
<option value="delivery">Envío a domicilio</option>
|
<option value="delivery">Envío a domicilio</option>
|
||||||
<option value="courier">Motociclista</option>
|
<option value="courier">Motociclista</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -224,21 +234,31 @@
|
|||||||
<select class="form-input" id="noCourier"></select>
|
<select class="form-input" id="noCourier"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
|
<label class="form-label" for="noMechanic">Mecánico asignado (usuario)</label>
|
||||||
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
|
<select class="form-input" id="noMechanic"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noMileage">Kilometraje</label>
|
<label class="form-label" for="noMechanicName">Mecánico asignado (nombre libre)</label>
|
||||||
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
|
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="noEstimatedCost">Presupuesto</label>
|
||||||
|
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="toolbar-toggle">
|
||||||
|
<input type="checkbox" id="noRequiresInvoice" />
|
||||||
|
<span>Requiere factura</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
<label class="toolbar-toggle">
|
<label class="toolbar-toggle">
|
||||||
<input type="checkbox" id="noDirect" />
|
<input type="checkbox" id="noDirect" />
|
||||||
<span>Orden directa (sin inventario)</span>
|
<span>Orden directa</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field form-field--span2">
|
||||||
<label class="form-label" for="noNotes">Notas de recepción</label>
|
<label class="form-label" for="noNotes">Observaciones</label>
|
||||||
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -270,63 +290,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="eoCustomerId" />
|
<input type="hidden" id="eoCustomerId" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span2" style="position:relative;">
|
|
||||||
<label class="form-label" for="eoVehicleSearch">Vehículo</label>
|
|
||||||
<div style="display:flex;gap:var(--space-2);">
|
|
||||||
<div style="position:relative;flex:1;">
|
|
||||||
<input class="form-input" id="eoVehicleSearch" autocomplete="off" placeholder="Buscar por placa..." oninput="Workshop.searchVehiclesForSO()" />
|
|
||||||
<div id="eoVehicleResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:180px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModal('edit')">+ Nuevo</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="eoVehicleId" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="eoBranch">Sucursal</label>
|
<label class="form-label" for="eoBranch">Sucursal</label>
|
||||||
<select class="form-input" id="eoBranch"></select>
|
<select class="form-input" id="eoBranch"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="eoMechanic">Mecánico asignado</label>
|
<label class="form-label" for="eoWorkshopName">Taller</label>
|
||||||
<select class="form-input" id="eoMechanic"><option value="">— Ninguno —</option></select>
|
<input class="form-input" id="eoWorkshopName" placeholder="Nombre del taller" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="eoPriority">Prioridad</label>
|
<label class="form-label" for="eoCustomerPhone">Teléfono</label>
|
||||||
<select class="form-input" id="eoPriority">
|
<input class="form-input" id="eoCustomerPhone" placeholder="3312345678" />
|
||||||
<option value="low">Baja</option>
|
|
||||||
<option value="normal">Normal</option>
|
|
||||||
<option value="high">Alta</option>
|
|
||||||
<option value="urgent">Urgente</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="eoFuelLevel">Nivel de combustible</label>
|
|
||||||
<select class="form-input" id="eoFuelLevel">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option value="empty">Vacío</option>
|
|
||||||
<option value="quarter">1/4</option>
|
|
||||||
<option value="half">1/2</option>
|
|
||||||
<option value="three_quarters">3/4</option>
|
|
||||||
<option value="full">Lleno</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="eoMileageIn">Kilometraje entrada</label>
|
|
||||||
<input class="form-input" type="number" id="eoMileageIn" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="eoMileageOut">Kilometraje salida</label>
|
|
||||||
<input class="form-input" type="number" id="eoMileageOut" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="eoEstimatedCompletion">Entrega estimada</label>
|
|
||||||
<input class="form-input" type="datetime-local" id="eoEstimatedCompletion" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="eoEstimatedCost">Costo estimado</label>
|
|
||||||
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field form-field--span2">
|
||||||
<label class="form-label" for="eoNotes">Notas de recepción</label>
|
<label class="form-label" for="eoCustomerAddress">Dirección</label>
|
||||||
|
<input class="form-input" id="eoCustomerAddress" placeholder="Calle, número, colonia" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="eoVehicleDescription">Vehículo</label>
|
||||||
|
<input class="form-input" id="eoVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="eoDelivery">Vía de entrega</label>
|
||||||
|
<select class="form-input" id="eoDelivery">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="pickup">Mostrador</option>
|
||||||
|
<option value="delivery">Envío a domicilio</option>
|
||||||
|
<option value="courier">Motociclista</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field" id="eoCourierField" style="display:none;">
|
||||||
|
<label class="form-label" for="eoCourier">Motociclista</label>
|
||||||
|
<select class="form-input" id="eoCourier"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="eoEstimatedCost">Presupuesto</label>
|
||||||
|
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="eoMechanic">Mecánico asignado (usuario)</label>
|
||||||
|
<select class="form-input" id="eoMechanic"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="eoMechanicName">Mecánico asignado (nombre libre)</label>
|
||||||
|
<input class="form-input" type="text" id="eoMechanicName" placeholder="Ej. Juan Pérez" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="toolbar-toggle">
|
||||||
|
<input type="checkbox" id="eoRequiresInvoice" />
|
||||||
|
<span>Requiere factura</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-field form-field--span2">
|
||||||
|
<label class="form-label" for="eoNotes">Observaciones</label>
|
||||||
<textarea class="form-input" id="eoNotes" rows="3"></textarea>
|
<textarea class="form-input" id="eoNotes" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -477,13 +493,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||||
<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=44" 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=42" defer></script>
|
<script src="/pos/static/js/workshop.js?v=55" 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>
|
||||||
|
|||||||
79
scripts/capture_rached_network.js
Normal file
79
scripts/capture_rached_network.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function apiLogin() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
grant_type: 'password',
|
||||||
|
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||||
|
client_id: 2,
|
||||||
|
username: 'IVAN@flechasyventiladores-rached.com',
|
||||||
|
password: 'Nexus01'
|
||||||
|
});
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'appapi.flechasyventiladores-rached.com',
|
||||||
|
path: '/api/seguridad/login',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||||
|
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||||
|
'Content-Length': Buffer.byteLength(payload)
|
||||||
|
}
|
||||||
|
}, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const outDir = path.resolve(__dirname, '../data/rached_import/har');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const loginResp = await apiLogin();
|
||||||
|
const sesion = JSON.stringify(loginResp.datos);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const allRequests = [];
|
||||||
|
await page.route('**/*', route => {
|
||||||
|
const req = route.request();
|
||||||
|
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
|
||||||
|
route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
'/administracion/servicios/ordenesservicio',
|
||||||
|
'/administracion/catalogos/clientes',
|
||||||
|
'/administracion/catalogos/articulos',
|
||||||
|
'/administracion/catalogos/mecanicos',
|
||||||
|
'/administracion/catalogos/sucursales',
|
||||||
|
'/administracion/catalogos/motociclistas',
|
||||||
|
'/administracion/catalogos/viasentrega',
|
||||||
|
'/administracion/catalogos/telefonostipos',
|
||||||
|
'/administracion/catalogos/direccionestipos',
|
||||||
|
];
|
||||||
|
for (const r of routes) {
|
||||||
|
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
|
||||||
|
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
|
||||||
|
await browser.close();
|
||||||
|
console.log('Captured', allRequests.length, 'total requests');
|
||||||
|
})();
|
||||||
442
scripts/import_rached_workshop.py
Normal file
442
scripts/import_rached_workshop.py
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
|
||||||
|
TENANT_DB = 'tenant_refaccionaria_rached'
|
||||||
|
|
||||||
|
# Load DB URL from environment or use localhost defaults
|
||||||
|
DB_URL = os.environ.get(
|
||||||
|
'TENANT_DB_URL',
|
||||||
|
f'postgresql://postgres@localhost/{TENANT_DB}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(s):
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(name):
|
||||||
|
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_detail_json(cid):
|
||||||
|
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
|
||||||
|
if path.exists():
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg2.connect(DB_URL)
|
||||||
|
|
||||||
|
|
||||||
|
ORDER_STATUS_MAP = {
|
||||||
|
1: 'por_recolectar',
|
||||||
|
2: 'por_revisar',
|
||||||
|
3: 'en_revision',
|
||||||
|
4: 'revisada',
|
||||||
|
5: 'cotizada',
|
||||||
|
6: 'por_autorizar',
|
||||||
|
7: 'autorizada',
|
||||||
|
8: 'autorizacion_parcial',
|
||||||
|
9: 'cancelada',
|
||||||
|
10: 'en_reparacion',
|
||||||
|
11: 'reparada',
|
||||||
|
12: 'por_entregar',
|
||||||
|
13: 'enviado',
|
||||||
|
14: 'entregado',
|
||||||
|
15: 'por_facturar',
|
||||||
|
16: 'facturada',
|
||||||
|
17: 'por_enviar',
|
||||||
|
}
|
||||||
|
|
||||||
|
ITEM_STATUS_MAP = {
|
||||||
|
1: 'por_revisar',
|
||||||
|
2: 'revisando',
|
||||||
|
3: 'revisado',
|
||||||
|
4: 'cotizado',
|
||||||
|
5: 'por_autorizar',
|
||||||
|
6: 'autorizado',
|
||||||
|
7: 'cancelado',
|
||||||
|
8: 'en_reparacion',
|
||||||
|
9: 'reparado',
|
||||||
|
10: 'por_entregar',
|
||||||
|
11: 'enviado',
|
||||||
|
12: 'entregado',
|
||||||
|
13: 'por_enviar',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_branch(cur):
|
||||||
|
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
return row[0]
|
||||||
|
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_branches(cur):
|
||||||
|
cur.execute("SELECT id, name FROM branches")
|
||||||
|
existing = {normalize_name(name): id for id, name in cur.fetchall()}
|
||||||
|
catalog = load_json('catalog_sucursales')['datos']
|
||||||
|
mapping = {}
|
||||||
|
for s in catalog:
|
||||||
|
name = (s.get('nombre') or '').strip()
|
||||||
|
# Skip the erroneous Rached branch
|
||||||
|
if name.upper() == 'CREMALLERAS':
|
||||||
|
continue
|
||||||
|
key = normalize_name(name)
|
||||||
|
if key in existing:
|
||||||
|
mapping[s['id']] = existing[key]
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
|
||||||
|
(s['nombre'],)
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
existing[key] = new_id
|
||||||
|
mapping[s['id']] = new_id
|
||||||
|
print(f"Created branch {s['nombre']} -> {new_id}")
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_customers(cur, branch_map):
|
||||||
|
# collect unique client ids referenced in orders
|
||||||
|
client_ids = set()
|
||||||
|
for f in DATA_DIR.glob('details/order_*.json'):
|
||||||
|
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
||||||
|
cid = d.get('idCliente')
|
||||||
|
if cid:
|
||||||
|
client_ids.add(cid)
|
||||||
|
|
||||||
|
# load existing customers by name
|
||||||
|
cur.execute("SELECT id, name FROM customers")
|
||||||
|
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||||
|
|
||||||
|
client_map = {}
|
||||||
|
default_branch = get_default_branch(cur)
|
||||||
|
for cid in sorted(client_ids):
|
||||||
|
detail = load_detail_json(cid)
|
||||||
|
datos = detail.get('datos', {}) if detail else {}
|
||||||
|
# Name: prefer 'taller' if meaningful, else full name
|
||||||
|
taller = (datos.get('taller') or '').strip()
|
||||||
|
nombre = (datos.get('nombre') or '').strip()
|
||||||
|
ap1 = (datos.get('primerApellido') or '').strip()
|
||||||
|
ap2 = (datos.get('segundoApellido') or '').strip()
|
||||||
|
if taller:
|
||||||
|
name = taller
|
||||||
|
else:
|
||||||
|
name = ' '.join([nombre, ap1, ap2]).strip()
|
||||||
|
if not name:
|
||||||
|
name = f"Cliente Rached {cid}"
|
||||||
|
|
||||||
|
if name.lower() in existing_by_name:
|
||||||
|
client_map[cid] = existing_by_name[name.lower()]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# phone / address
|
||||||
|
phone = None
|
||||||
|
for t in datos.get('telefonos', []):
|
||||||
|
num = t.get('numero')
|
||||||
|
if num:
|
||||||
|
phone = str(num)
|
||||||
|
break
|
||||||
|
address = None
|
||||||
|
for drec in datos.get('direcciones', []):
|
||||||
|
address = drec.get('completa')
|
||||||
|
if address:
|
||||||
|
break
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
|
||||||
|
""",
|
||||||
|
(default_branch, name, phone, address)
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
existing_by_name[name.lower()] = new_id
|
||||||
|
client_map[cid] = new_id
|
||||||
|
print(f"Created customer {cid}: {name}")
|
||||||
|
return client_map
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
|
||||||
|
default_branch = get_default_branch(cur)
|
||||||
|
emp_map = {}
|
||||||
|
if existing_names is None:
|
||||||
|
cur.execute("SELECT id, name FROM employees")
|
||||||
|
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||||
|
for e in catalog:
|
||||||
|
name = (e.get('nombre') or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
key = name.lower()
|
||||||
|
if key in existing_names:
|
||||||
|
emp_map[e['id']] = existing_names[key]
|
||||||
|
continue
|
||||||
|
is_active = bool(e.get('activo', True))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO employees (name, role, branch_id, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s) RETURNING id
|
||||||
|
""",
|
||||||
|
(name, role, default_branch, is_active)
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
existing_names[key] = new_id
|
||||||
|
emp_map[e['id']] = new_id
|
||||||
|
print(f"Created employee {role} {e['id']}: {name}")
|
||||||
|
return emp_map
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_couriers(cur, catalog, tenant_id=31):
|
||||||
|
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
|
||||||
|
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||||
|
courier_map = {}
|
||||||
|
for c in catalog:
|
||||||
|
name = (c.get('nombre') or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
key = name.lower()
|
||||||
|
if key in existing:
|
||||||
|
courier_map[c['id']] = existing[key]
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
||||||
|
(tenant_id, name, f"MOT-{c['id']}")
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
existing[key] = new_id
|
||||||
|
courier_map[c['id']] = new_id
|
||||||
|
print(f"Created courier {c['id']}: {name}")
|
||||||
|
return courier_map
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_articles(cur, branch_map):
|
||||||
|
"""Rached articles are kept as free-text lines, not inventory products."""
|
||||||
|
catalog = load_json('catalog_articulos')
|
||||||
|
article_map = {}
|
||||||
|
for a in catalog:
|
||||||
|
name = (a.get('nombre') or '').strip()
|
||||||
|
if name:
|
||||||
|
article_map[a['id']] = name
|
||||||
|
return article_map
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_users(cur, branch_map, order_details):
|
||||||
|
# Map Rached user ids to employees
|
||||||
|
default_branch = get_default_branch(cur)
|
||||||
|
cur.execute("SELECT id, name FROM employees")
|
||||||
|
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||||
|
user_map = {}
|
||||||
|
for d in order_details:
|
||||||
|
datos = d.get('datos', {})
|
||||||
|
uid = datos.get('idUsuario')
|
||||||
|
name = (datos.get('usuarioNombre') or '').strip()
|
||||||
|
if not uid or not name:
|
||||||
|
continue
|
||||||
|
key = name.lower()
|
||||||
|
if key in existing:
|
||||||
|
user_map[uid] = existing[key]
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
||||||
|
(name, 'workshop', default_branch)
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()[0]
|
||||||
|
existing[key] = new_id
|
||||||
|
user_map[uid] = new_id
|
||||||
|
print(f"Created user {uid}: {name}")
|
||||||
|
return user_map
|
||||||
|
|
||||||
|
|
||||||
|
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
|
||||||
|
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
for f in order_files:
|
||||||
|
d = json.loads(f.read_text(encoding='utf-8'))
|
||||||
|
datos = d.get('datos', {})
|
||||||
|
order_number = datos.get('numero')
|
||||||
|
if not order_number:
|
||||||
|
continue
|
||||||
|
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
|
||||||
|
if cur.fetchone():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
branch_id = branch_map.get(datos.get('idSucursal'))
|
||||||
|
customer_id = client_map.get(datos.get('idCliente'))
|
||||||
|
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
|
||||||
|
|
||||||
|
# delivery method
|
||||||
|
via = datos.get('idViaEntrega')
|
||||||
|
mot_rec = datos.get('idMotociclistaRecoleccion')
|
||||||
|
mot_ent = datos.get('idMotociclistaEntrega')
|
||||||
|
courier_id = None
|
||||||
|
if via == 1:
|
||||||
|
delivery_method = 'pickup'
|
||||||
|
elif via == 2:
|
||||||
|
if mot_ent:
|
||||||
|
delivery_method = 'courier'
|
||||||
|
courier_id = courier_map.get(mot_ent)
|
||||||
|
elif mot_rec:
|
||||||
|
delivery_method = 'courier'
|
||||||
|
courier_id = courier_map.get(mot_rec)
|
||||||
|
else:
|
||||||
|
delivery_method = 'delivery'
|
||||||
|
else:
|
||||||
|
delivery_method = None
|
||||||
|
|
||||||
|
# customer address/phone from order overrides if present? order detail only has ids.
|
||||||
|
# Prefill from customer record (already stored in customers)
|
||||||
|
workshop_name = None
|
||||||
|
customer_phone = None
|
||||||
|
customer_address = None
|
||||||
|
if customer_id:
|
||||||
|
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
workshop_name, customer_phone, customer_address = row
|
||||||
|
|
||||||
|
created_by = user_map.get(datos.get('idUsuario'))
|
||||||
|
fecha = datos.get('fecha')
|
||||||
|
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_orders
|
||||||
|
(tenant_id, branch_id, customer_id, order_number, status,
|
||||||
|
workshop_name, customer_address, customer_phone, vehicle_description,
|
||||||
|
reception_notes, estimated_cost, final_cost,
|
||||||
|
delivery_method, courier_id, is_direct, requires_invoice,
|
||||||
|
created_by, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
31, # tenant_id for Rached
|
||||||
|
branch_id,
|
||||||
|
customer_id,
|
||||||
|
order_number,
|
||||||
|
status,
|
||||||
|
workshop_name,
|
||||||
|
customer_address,
|
||||||
|
customer_phone,
|
||||||
|
datos.get('vehiculo'),
|
||||||
|
datos.get('observaciones'),
|
||||||
|
datos.get('presupuesto') or 0,
|
||||||
|
datos.get('total') or 0,
|
||||||
|
delivery_method,
|
||||||
|
courier_id,
|
||||||
|
bool(datos.get('ordenDirecta')),
|
||||||
|
bool(datos.get('requiereFactura')),
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
so_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# status history
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(so_id, status, created_by, 'Importado desde app Rached', created_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
# items (free-text lines; not linked to inventory)
|
||||||
|
for it in datos.get('detalles', []):
|
||||||
|
articulo = it.get('articulo', {})
|
||||||
|
art_id = articulo.get('id')
|
||||||
|
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
|
||||||
|
mech_id = mech_map.get(it.get('idMecanico'))
|
||||||
|
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
|
||||||
|
qty = it.get('cantidad', 1)
|
||||||
|
price = it.get('precio') or 0
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_order_items
|
||||||
|
(service_order_id, inventory_id, part_number, name, quantity,
|
||||||
|
unit_cost, unit_price, status, mechanic_id, observations)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
so_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
art_name,
|
||||||
|
qty,
|
||||||
|
price,
|
||||||
|
price,
|
||||||
|
item_status,
|
||||||
|
mech_id,
|
||||||
|
it.get('observaciones'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
imported += 1
|
||||||
|
if imported % 100 == 0:
|
||||||
|
print(f"Imported {imported} orders...")
|
||||||
|
|
||||||
|
return imported, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Importing Rached workshop data into {TENANT_DB}")
|
||||||
|
conn = connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
branch_map = ensure_branches(cur)
|
||||||
|
print(f"Branch map: {branch_map}")
|
||||||
|
|
||||||
|
client_map = ensure_customers(cur, branch_map)
|
||||||
|
print(f"Customers to import: {len(client_map)}")
|
||||||
|
|
||||||
|
mech_catalog = load_json('catalog_mecanicos')
|
||||||
|
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
|
||||||
|
print(f"Mechanics map: {len(mech_map)}")
|
||||||
|
|
||||||
|
courier_catalog = load_json('catalog_motociclistas')
|
||||||
|
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
|
||||||
|
print(f"Couriers map: {len(courier_map)}")
|
||||||
|
|
||||||
|
article_map = ensure_articles(cur, branch_map)
|
||||||
|
print(f"Articles map: {len(article_map)}")
|
||||||
|
|
||||||
|
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
|
||||||
|
user_map = ensure_users(cur, branch_map, order_details)
|
||||||
|
print(f"Users map: {len(user_map)}")
|
||||||
|
|
||||||
|
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
|
||||||
|
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
32
scripts/inspect_rached_after_login.js
Normal file
32
scripts/inspect_rached_after_login.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('correo').removeAttribute('pattern');
|
||||||
|
document.getElementById('password').removeAttribute('pattern');
|
||||||
|
});
|
||||||
|
await page.fill('#correo', 'IVAN@flechasyventiladores-rached.com');
|
||||||
|
await page.fill('#password', 'Nexus01');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
['correo','password'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login_filled.png', fullPage: true });
|
||||||
|
fs.writeFileSync('/home/Autopartes/data/rached_import/login_filled.html', await page.content());
|
||||||
|
// click enabled submit button if any
|
||||||
|
await page.click('button[color="primary"]');
|
||||||
|
await page.waitForTimeout(8000);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/after_login.png', fullPage: true });
|
||||||
|
fs.writeFileSync('/home/Autopartes/data/rached_import/after_login.html', await page.content());
|
||||||
|
console.log('done');
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
14
scripts/inspect_rached_login.js
Normal file
14
scripts/inspect_rached_login.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login.png', fullPage: true });
|
||||||
|
fs.writeFileSync('/home/Autopartes/data/rached_import/login.html', await page.content());
|
||||||
|
console.log('done');
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
175
scripts/scrape_rached_catalogs.py
Normal file
175
scripts/scrape_rached_catalogs.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scrape Rached legacy catalog endpoints discovered via UI."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||||
|
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||||
|
USER = 'IVAN@flechasyventiladores-rached.com'
|
||||||
|
PASS = 'Nexus01'
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||||
|
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
url = f'{BASE_API}/api/seguridad/login'
|
||||||
|
payload = {
|
||||||
|
'grant_type': 'password',
|
||||||
|
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||||
|
'client_id': 2,
|
||||||
|
'username': USER,
|
||||||
|
'password': PASS,
|
||||||
|
}
|
||||||
|
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('mensaje'):
|
||||||
|
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||||
|
return data['datos']['token']
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers(token):
|
||||||
|
return {**HEADERS, 'Authorization': f'Bearer {token}'}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_get(token, path, params=None):
|
||||||
|
url = f'{BASE_API}{path}'
|
||||||
|
resp = requests.get(url, headers=api_headers(token), params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_post(token, path, payload=None, params=None):
|
||||||
|
url = f'{BASE_API}{path}'
|
||||||
|
resp = requests.post(url, headers=api_headers(token), json=payload or {}, params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def save(name, data):
|
||||||
|
path = OUT_DIR / f'{name}.json'
|
||||||
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_list_all(token, path, method='GET', payload=None):
|
||||||
|
"""Fetch a list endpoint that may return either a list or Laravel pagination."""
|
||||||
|
all_items = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if method == 'GET':
|
||||||
|
data = fetch_get(token, f'{path}?page={page}')
|
||||||
|
else:
|
||||||
|
data = fetch_post(token, f'{path}?page={page}', payload)
|
||||||
|
except Exception as e:
|
||||||
|
print(f' {path} page {page} error: {e}')
|
||||||
|
break
|
||||||
|
datos = data.get('datos', {})
|
||||||
|
if isinstance(datos, list):
|
||||||
|
# Non-paginated full list
|
||||||
|
all_items.extend(datos)
|
||||||
|
print(f' {path}: +{len(datos)} (total {len(all_items)})')
|
||||||
|
break
|
||||||
|
items = datos.get('data', [])
|
||||||
|
all_items.extend(items)
|
||||||
|
print(f' {path} page {page}: +{len(items)} (total {len(all_items)}/{datos.get("total", "?")})')
|
||||||
|
if datos.get('current_page', 1) >= datos.get('last_page', 1):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
time.sleep(0.6)
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = OUT_DIR / 'scrape_catalogs.log'
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(msg)
|
||||||
|
with open(log_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(msg + '\n')
|
||||||
|
|
||||||
|
log('Logging in...')
|
||||||
|
token = login()
|
||||||
|
log('Logged in.')
|
||||||
|
|
||||||
|
# Simple catalog endpoints (GET, single page or paginated)
|
||||||
|
simple_gets = {
|
||||||
|
'catalog_sucursales': '/api/catalogos/sucursales',
|
||||||
|
'catalog_viasentrega': '/api/catalogos/viasentrega/listado',
|
||||||
|
'catalog_order_statuses': '/api/catalogos/ordenesservicioestatus/listado',
|
||||||
|
'catalog_item_statuses': '/api/catalogos/ordenesserviciodetalleestatus/listado',
|
||||||
|
'catalog_directas': '/api/catalogos/ordenesDirectas',
|
||||||
|
'catalog_telefonostipos': '/api/catalogos/telefonostipos/listado',
|
||||||
|
'catalog_direccionestipos': '/api/catalogos/direccionestipos/listado',
|
||||||
|
}
|
||||||
|
for name, path in simple_gets.items():
|
||||||
|
try:
|
||||||
|
data = fetch_get(token, path)
|
||||||
|
save(name, data)
|
||||||
|
count = len(data.get('datos', []) if isinstance(data.get('datos'), list) else [])
|
||||||
|
log(f'{name}: {count} items')
|
||||||
|
except Exception as e:
|
||||||
|
log(f'{name} error: {e}')
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
|
# List endpoints (GET or POST)
|
||||||
|
list_gets = {
|
||||||
|
'catalog_articulos': '/api/catalogos/articulos/listado',
|
||||||
|
'catalog_mecanicos': '/api/catalogos/mecanicos/listado',
|
||||||
|
'catalog_motociclistas': '/api/catalogos/motociclistas/listado',
|
||||||
|
}
|
||||||
|
for name, path in list_gets.items():
|
||||||
|
items = fetch_list_all(token, path, method='GET')
|
||||||
|
save(name, items)
|
||||||
|
log(f'{name}: {len(items)} items')
|
||||||
|
|
||||||
|
# Client list
|
||||||
|
try:
|
||||||
|
client_items = fetch_list_all(token, '/api/catalogos/clientes/listado', method='POST',
|
||||||
|
payload={'taller': '', 'nombreCompleto': '', 'idClienteEstatus': '-1'})
|
||||||
|
save('catalog_clientes', client_items)
|
||||||
|
log(f'catalog_clientes: {len(client_items)} items')
|
||||||
|
|
||||||
|
# Fetch full client details for clients referenced in orders
|
||||||
|
orders_path = OUT_DIR / 'orders_list.json'
|
||||||
|
if orders_path.exists():
|
||||||
|
order_ids = {o['idCliente'] for o in json.loads(orders_path.read_text(encoding='utf-8')) if o.get('idCliente')}
|
||||||
|
log(f'Fetching details for {len(order_ids)} unique order clients...')
|
||||||
|
client_details = {}
|
||||||
|
for idx, cid in enumerate(sorted(order_ids), 1):
|
||||||
|
detail_path = OUT_DIR / 'client_details' / f'client_{cid}.json'
|
||||||
|
if detail_path.exists():
|
||||||
|
client_details[cid] = json.loads(detail_path.read_text(encoding='utf-8'))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = fetch_get(token, f'/api/catalogos/clientes/{cid}')
|
||||||
|
client_details[cid] = data.get('datos')
|
||||||
|
detail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
log(f' client {cid} detail error: {e}')
|
||||||
|
if idx % 30 == 0:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
save('catalog_client_details', client_details)
|
||||||
|
log(f'catalog_client_details: {len(client_details)} items')
|
||||||
|
except Exception as e:
|
||||||
|
log(f'catalog_clientes error: {e}')
|
||||||
|
|
||||||
|
log('Done.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
85
scripts/scrape_rached_client_details.py
Normal file
85
scripts/scrape_rached_client_details.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch full client details for clients referenced in Rached service orders."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||||
|
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||||
|
PASS = 'Nexus01'
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||||
|
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
url = f'{BASE_API}/api/seguridad/login'
|
||||||
|
payload = {
|
||||||
|
'grant_type': 'password',
|
||||||
|
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||||
|
'client_id': 2,
|
||||||
|
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||||
|
'password': PASS,
|
||||||
|
}
|
||||||
|
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('mensaje'):
|
||||||
|
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||||
|
return data['datos']['token']
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_client(token, cid):
|
||||||
|
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
|
||||||
|
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
detail_dir = OUT_DIR / 'client_details'
|
||||||
|
detail_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# collect unique client ids from order details
|
||||||
|
client_ids = set()
|
||||||
|
for f in OUT_DIR.glob('details/order_*.json'):
|
||||||
|
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
||||||
|
cid = d.get('idCliente')
|
||||||
|
if cid:
|
||||||
|
client_ids.add(cid)
|
||||||
|
|
||||||
|
print(f'Found {len(client_ids)} unique clients from order details')
|
||||||
|
token = login()
|
||||||
|
print('Logged in')
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for idx, cid in enumerate(sorted(client_ids), 1):
|
||||||
|
out_path = detail_dir / f'client_{cid}.json'
|
||||||
|
if out_path.exists():
|
||||||
|
data = json.loads(out_path.read_text(encoding='utf-8'))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = fetch_client(token, cid)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[{idx}/{len(client_ids)}] client {cid} error: {e}')
|
||||||
|
continue
|
||||||
|
out_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
results[cid] = data.get('datos')
|
||||||
|
print(f'[{idx}/{len(client_ids)}] client {cid} saved')
|
||||||
|
time.sleep(0.4 if idx % 30 else 1.5)
|
||||||
|
|
||||||
|
summary_path = OUT_DIR / 'catalog_client_details.json'
|
||||||
|
summary_path.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
print(f'Saved {len(results)} client details to {summary_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
91
scripts/scrape_rached_client_details_retry.py
Normal file
91
scripts/scrape_rached_client_details_retry.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Retry fetching missing Rached client details with slower rate."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||||
|
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||||
|
PASS = 'Nexus01'
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||||
|
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
url = f'{BASE_API}/api/seguridad/login'
|
||||||
|
payload = {
|
||||||
|
'grant_type': 'password',
|
||||||
|
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||||
|
'client_id': 2,
|
||||||
|
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||||
|
'password': PASS,
|
||||||
|
}
|
||||||
|
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('mensaje'):
|
||||||
|
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||||
|
return data['datos']['token']
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_client(token, cid, attempts=5):
|
||||||
|
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
|
||||||
|
for attempt in range(attempts):
|
||||||
|
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
wait = 5 + attempt * 3
|
||||||
|
print(f' client {cid} 429, waiting {wait}s')
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
raise RuntimeError(f'client {cid} rate limited')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
detail_dir = OUT_DIR / 'client_details'
|
||||||
|
detail_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
needed = set()
|
||||||
|
for f in OUT_DIR.glob('details/order_*.json'):
|
||||||
|
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
||||||
|
cid = d.get('idCliente')
|
||||||
|
if cid:
|
||||||
|
needed.add(cid)
|
||||||
|
|
||||||
|
saved = set(int(p.name.split('_')[1].split('.')[0]) for p in detail_dir.glob('client_*.json'))
|
||||||
|
missing = sorted(needed - saved)
|
||||||
|
print(f'Need {len(needed)}, have {len(saved)}, missing {len(missing)}')
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
token = login()
|
||||||
|
results = json.loads((OUT_DIR / 'catalog_client_details.json').read_text(encoding='utf-8')) if (OUT_DIR / 'catalog_client_details.json').exists() else {}
|
||||||
|
|
||||||
|
for idx, cid in enumerate(missing, 1):
|
||||||
|
try:
|
||||||
|
data = fetch_client(token, cid)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[{idx}/{len(missing)}] client {cid} final error: {e}')
|
||||||
|
continue
|
||||||
|
detail_path = detail_dir / f'client_{cid}.json'
|
||||||
|
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
results[str(cid)] = data.get('datos')
|
||||||
|
print(f'[{idx}/{len(missing)}] client {cid} saved')
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
(OUT_DIR / 'catalog_client_details.json').write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
print(f'Saved {len(results)} client details')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
162
scripts/scrape_rached_workshop.js
Normal file
162
scripts/scrape_rached_workshop.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Scrape service orders from Rached's legacy web app.
|
||||||
|
* Run with:
|
||||||
|
* RACHED_USER='IVAN' RACHED_PASS='Nexus01' node scripts/scrape_rached_workshop.js
|
||||||
|
*/
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT_DIR = process.env.OUT_DIR || path.join(__dirname, '..', 'data', 'rached_import');
|
||||||
|
const USER = process.env.RACHED_USER || 'IVAN';
|
||||||
|
const PASS = process.env.RACHED_PASS || 'Nexus01';
|
||||||
|
const BASE_APP = 'http://app.flechasyventiladores-rached.com';
|
||||||
|
const BASE_API = 'http://appapi.flechasyventiladores-rached.com';
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
viewport: { width: 1440, height: 900 }
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// ── LOGIN ──
|
||||||
|
console.log('Logging in...');
|
||||||
|
await page.goto(`${BASE_APP}/login`);
|
||||||
|
await page.waitForSelector('#correo', { timeout: 10000 });
|
||||||
|
await page.fill('#correo', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('button:has-text("Aceptar")');
|
||||||
|
await page.waitForURL('**/administracion/**', { timeout: 20000 });
|
||||||
|
console.log('Logged in.');
|
||||||
|
|
||||||
|
// ── ORDERS LIST ──
|
||||||
|
console.log('Navigating to orders list...');
|
||||||
|
const allOrders = [];
|
||||||
|
let pageNum = 1;
|
||||||
|
let respPromise = page.waitForResponse(
|
||||||
|
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
await page.goto(`${BASE_APP}/administracion/servicios/ordenesservicio`);
|
||||||
|
while (true) {
|
||||||
|
if (pageNum > 1) {
|
||||||
|
const nextBtn = page.locator('button.mat-paginator-navigation-next');
|
||||||
|
const isDisabled = await nextBtn.evaluate(el => el.disabled).catch(() => true);
|
||||||
|
if (isDisabled) {
|
||||||
|
console.log('No more pages.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
respPromise = page.waitForResponse(
|
||||||
|
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
await nextBtn.click();
|
||||||
|
}
|
||||||
|
const resp = await respPromise;
|
||||||
|
const payload = await resp.json();
|
||||||
|
const datos = payload.datos || {};
|
||||||
|
const orders = datos.data || [];
|
||||||
|
allOrders.push(...orders);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUT_DIR, `orders_page_${pageNum}.json`),
|
||||||
|
JSON.stringify(payload, null, 2)
|
||||||
|
);
|
||||||
|
console.log(`Page ${pageNum}: ${orders.length} orders (total ${allOrders.length}/${datos.total || '?'})`);
|
||||||
|
if (datos.current_page >= datos.last_page) break;
|
||||||
|
pageNum++;
|
||||||
|
await sleep(800);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUT_DIR, 'orders_list.json'),
|
||||||
|
JSON.stringify(allOrders, null, 2)
|
||||||
|
);
|
||||||
|
console.log(`Collected ${allOrders.length} orders summary.`);
|
||||||
|
await page.waitForSelector('mat-row', { timeout: 10000 });
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
if (!allOrders.length) {
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ORDER DETAILS ──
|
||||||
|
const detailDir = path.join(OUT_DIR, 'details');
|
||||||
|
fs.mkdirSync(detailDir, { recursive: true });
|
||||||
|
|
||||||
|
let targetId = null;
|
||||||
|
await page.route('**/api/servicios/ordenes/*', async (route, request) => {
|
||||||
|
if (targetId && request.url().match(/\/ordenes\/\d+$/)) {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const parts = url.pathname.split('/');
|
||||||
|
parts[parts.length - 1] = String(targetId);
|
||||||
|
url.pathname = parts.join('/');
|
||||||
|
await route.continue({ url: url.toString() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clickFirstEditButton() {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const row = document.querySelector('mat-row');
|
||||||
|
if (!row) return;
|
||||||
|
const btn = row.querySelector('button.mat-icon-button i.fa-edit');
|
||||||
|
if (btn) btn.closest('button').click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(process.env.LIMIT || '0', 10);
|
||||||
|
const ordersToFetch = limit > 0 ? allOrders.slice(0, limit) : allOrders;
|
||||||
|
for (let i = 0; i < ordersToFetch.length; i++) {
|
||||||
|
const o = ordersToFetch[i];
|
||||||
|
targetId = o.id;
|
||||||
|
const detailPath = path.join(detailDir, `order_${targetId}.json`);
|
||||||
|
if (fs.existsSync(detailPath)) {
|
||||||
|
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} (already saved) `);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 3) {
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
const respPromise = page.waitForResponse(
|
||||||
|
r => r.url().includes(`/api/servicios/ordenes/${targetId}`) && r.status() === 200,
|
||||||
|
{ timeout: 20000 }
|
||||||
|
);
|
||||||
|
await clickFirstEditButton();
|
||||||
|
const resp = await respPromise;
|
||||||
|
const detail = await resp.json();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(detailDir, `order_${targetId}.json`),
|
||||||
|
JSON.stringify(detail, null, 2)
|
||||||
|
);
|
||||||
|
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} `);
|
||||||
|
// close modal if opened
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await sleep(300);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`\nError fetching detail for ${targetId} (attempt ${attempts}):`, e.message);
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('\nDetail extraction complete.');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`Output saved to ${OUT_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
144
scripts/scrape_rached_workshop.py
Normal file
144
scripts/scrape_rached_workshop.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scrape service orders from Rached legacy web app using their internal API."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_APP = 'http://app.flechasyventiladores-rached.com'
|
||||||
|
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||||
|
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||||
|
USER = os.environ.get('RACHED_USER', 'IVAN')
|
||||||
|
PASS = os.environ.get('RACHED_PASS', 'Nexus01')
|
||||||
|
LIMIT = int(os.environ.get('LIMIT', '0'))
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Origin': BASE_APP,
|
||||||
|
'Referer': f'{BASE_APP}/',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
url = f'{BASE_API}/api/seguridad/login'
|
||||||
|
payload = {
|
||||||
|
'grant_type': 'password',
|
||||||
|
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||||
|
'client_id': 2,
|
||||||
|
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||||
|
'password': PASS,
|
||||||
|
}
|
||||||
|
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('mensaje'):
|
||||||
|
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||||
|
return data['datos']['token']
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers(token):
|
||||||
|
return {**HEADERS, 'Authorization': f'Bearer {token}'}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_list(token, page):
|
||||||
|
url = f'{BASE_API}/api/servicios/ordenes/listado?page={page}'
|
||||||
|
resp = requests.post(url, headers=api_headers(token))
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_detail(token, order_id):
|
||||||
|
url = f'{BASE_API}/api/servicios/ordenes/{order_id}'
|
||||||
|
resp = requests.get(url, headers=api_headers(token))
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = OUT_DIR / 'scrape_py.log'
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(msg)
|
||||||
|
with open(log_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(msg + '\n')
|
||||||
|
|
||||||
|
log('Logging in...')
|
||||||
|
token = login()
|
||||||
|
log('Logged in.')
|
||||||
|
|
||||||
|
# ── List ──
|
||||||
|
list_path = OUT_DIR / 'orders_list.json'
|
||||||
|
if list_path.exists():
|
||||||
|
all_orders = json.loads(list_path.read_text(encoding='utf-8'))
|
||||||
|
log(f'Loaded {len(all_orders)} orders from existing list.')
|
||||||
|
else:
|
||||||
|
all_orders = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
data = fetch_list(token, page)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log(f'List page {page} attempt {attempt+1} error: {e}')
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
log(f'Failed to fetch list page {page} after 3 attempts.')
|
||||||
|
break
|
||||||
|
|
||||||
|
datos = data.get('datos', {})
|
||||||
|
orders = datos.get('data', [])
|
||||||
|
all_orders.extend(orders)
|
||||||
|
(OUT_DIR / f'orders_page_{page}.json').write_text(
|
||||||
|
json.dumps(data, indent=2, ensure_ascii=False),
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
log(f'Page {page}: {len(orders)} orders (total {len(all_orders)}/{datos.get("total", "?")})')
|
||||||
|
if datos.get('current_page', 1) >= datos.get('last_page', 1):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
list_path.write_text(json.dumps(all_orders, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
log(f'Collected {len(all_orders)} orders summary.')
|
||||||
|
|
||||||
|
if not all_orders:
|
||||||
|
log('No orders to fetch details for.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Details ──
|
||||||
|
detail_dir = OUT_DIR / 'details'
|
||||||
|
detail_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
target_orders = all_orders[:LIMIT] if LIMIT > 0 else all_orders
|
||||||
|
total = len(target_orders)
|
||||||
|
for i, o in enumerate(target_orders, start=1):
|
||||||
|
oid = o['id']
|
||||||
|
detail_path = detail_dir / f'order_{oid}.json'
|
||||||
|
if detail_path.exists():
|
||||||
|
log(f'[{i}/{total}] {oid} already saved')
|
||||||
|
continue
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
data = fetch_detail(token, oid)
|
||||||
|
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||||
|
log(f'[{i}/{total}] {oid} saved')
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log(f'[{i}/{total}] {oid} attempt {attempt+1} error: {e}')
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
log(f'[{i}/{total}] {oid} FAILED after 3 attempts')
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
log('Done.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
25
scripts/test_cashier_pos.js
Normal file
25
scripts/test_cashier_pos.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="33"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||||
|
await page.goto('/pos/sale');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/cashier_pos_default.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
37
scripts/test_counter_dashboard.js
Normal file
37
scripts/test_counter_dashboard.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="32"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||||
|
await page.goto('/pos/catalog');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const info = await page.evaluate(() => ({
|
||||||
|
title: document.title,
|
||||||
|
bodyClass: document.body.className,
|
||||||
|
scripts: Array.from(document.querySelectorAll('script[src]')).map(s => s.src).filter(s => s.includes('sidebar') || s.includes('app-init'))
|
||||||
|
}));
|
||||||
|
console.log('Page info:', JSON.stringify(info, null, 2));
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_sidebar_catalog.png', fullPage: true });
|
||||||
|
|
||||||
|
// Try dashboard
|
||||||
|
await page.goto('/pos/dashboard');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_dashboard_redirect.png', fullPage: true });
|
||||||
|
console.log('URL after dashboard:', page.url());
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
31
scripts/test_inventory_entrada.js
Normal file
31
scripts/test_inventory_entrada.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="1"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||||
|
await page.goto('/pos/inventory');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.click('button:has-text("Entradas")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.click('button:has-text("Nueva Entrada")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.fill('#purchaseItemSearch', 'CVBT2170');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/inventory_entrada_alias.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
25
scripts/test_pos_counter_price.js
Normal file
25
scripts/test_pos_counter_price.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="31"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||||
|
await page.goto('/pos/sale');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/pos_counter_debug.png', fullPage: true });
|
||||||
|
console.log('URL:', page.url());
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
28
scripts/test_reports_cortes.js
Normal file
28
scripts/test_reports_cortes.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="1"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||||
|
await page.goto('/pos/reports');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await page.click('button:has-text("Cortes de caja")');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/reports_cortes_tab.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
35
scripts/test_workshop_counter.js
Normal file
35
scripts/test_workshop_counter.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { errors.push('CONSOLE(' + msg.type() + '): ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="29"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_list.png', fullPage: true });
|
||||||
|
|
||||||
|
await page.click('button:has-text("Ver")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_detail.png', fullPage: true });
|
||||||
|
await page.click('button:has-text("Cerrar")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('#btnNewOrder');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_new.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
34
scripts/test_workshop_ui.js
Normal file
34
scripts/test_workshop_ui.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||||
|
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||||
|
|
||||||
|
await page.goto('/pos/login2?tenant=31');
|
||||||
|
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||||
|
await page.click('.user-avatar-btn[data-id="2"]');
|
||||||
|
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||||
|
await page.click('#btn-login');
|
||||||
|
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_list.png', fullPage: true });
|
||||||
|
|
||||||
|
await page.click('button:has-text("Ver")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_detail.png', fullPage: true });
|
||||||
|
|
||||||
|
await page.click('button:has-text("Artículos"), .so-tabs__btn:has-text("Artículos")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_articles.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' -', e));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user