feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,10 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/css/tokens.css">
|
<link rel="stylesheet" href="/static/css/tokens.css">
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Reset ── */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html { scroll-behavior: smooth; }
|
html { scroll-behavior: smooth; scrollbar-width: none; }
|
||||||
|
html::-webkit-scrollbar { width: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -21,15 +23,24 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
line-height: var(--leading-body);
|
line-height: var(--leading-body);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
a { color: var(--color-text-accent); text-decoration: none; }
|
a { color: var(--color-text-accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ==========================================================================
|
||||||
|
HEADER — Glassmorphism sticky (matches landing)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
position: sticky; top: 0; z-index: var(--z-sticky);
|
position: sticky; top: 0;
|
||||||
background: var(--color-bg-elevated);
|
z-index: var(--z-sticky);
|
||||||
border-bottom: 1px solid var(--color-border);
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
.site-header .inner {
|
.site-header .inner {
|
||||||
max-width: var(--content-xl); margin: 0 auto;
|
max-width: var(--content-xl); margin: 0 auto;
|
||||||
@@ -42,17 +53,82 @@
|
|||||||
font-weight: var(--heading-weight-primary);
|
font-weight: var(--heading-weight-primary);
|
||||||
color: var(--color-text-accent);
|
color: var(--color-text-accent);
|
||||||
text-transform: uppercase; letter-spacing: var(--tracking-wide);
|
text-transform: uppercase; letter-spacing: var(--tracking-wide);
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.logo::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -3px; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gradient-accent);
|
||||||
|
border-radius: 1px;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: blur(1px);
|
||||||
}
|
}
|
||||||
.header-right { display: flex; gap: var(--space-3); align-items: center; }
|
.header-right { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
.theme-toggle {
|
|
||||||
background: var(--btn-ghost-bg); border: 1px solid var(--btn-ghost-border);
|
|
||||||
color: var(--btn-ghost-text); padding: var(--space-1) var(--space-3);
|
|
||||||
border-radius: var(--radius-md); cursor: pointer; font-size: var(--text-caption);
|
|
||||||
font-family: var(--font-body); transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
.theme-toggle:hover { background: var(--color-primary-muted); color: var(--color-text-accent); }
|
|
||||||
|
|
||||||
/* ── Search bar ── */
|
/* ── Catalog mode toggle (OEM / Local) ── */
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.mode-toggle button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: calc(var(--radius-md) - 3px);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
.mode-toggle button:hover {
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
.mode-toggle button.is-active {
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer; font-size: 1rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
.header-back {
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.header-back:hover { color: var(--color-text-accent); text-decoration: none; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SEARCH BAR — Glass style
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
max-width: var(--content-xl); margin: 0 auto;
|
max-width: var(--content-xl); margin: 0 auto;
|
||||||
padding: var(--space-4) var(--space-6);
|
padding: var(--space-4) var(--space-6);
|
||||||
@@ -63,34 +139,65 @@
|
|||||||
.search-wrapper input {
|
.search-wrapper input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
background: var(--color-surface-1);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--color-border);
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: var(--transition-fast);
|
transition: all 0.25s var(--ease-out);
|
||||||
}
|
}
|
||||||
.search-wrapper input:focus {
|
.search-wrapper input:focus {
|
||||||
border-color: var(--color-border-focus);
|
border-color: var(--color-border-focus);
|
||||||
box-shadow: var(--shadow-focus);
|
box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 20px var(--glow-color-soft);
|
||||||
}
|
}
|
||||||
.search-wrapper input::placeholder { color: var(--color-text-muted); }
|
.search-wrapper input::placeholder { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* 3D search button */
|
||||||
.search-wrapper button {
|
.search-wrapper button {
|
||||||
padding: var(--space-3) var(--space-5);
|
padding: var(--space-3) var(--space-5);
|
||||||
background: var(--btn-primary-bg); color: var(--btn-primary-text);
|
background: var(--gradient-accent);
|
||||||
|
color: var(--btn-primary-text);
|
||||||
border: none; border-radius: var(--radius-md);
|
border: none; border-radius: var(--radius-md);
|
||||||
font-family: var(--font-body); font-size: var(--text-body);
|
font-family: var(--font-body); font-size: var(--text-body);
|
||||||
font-weight: var(--font-weight-semibold); cursor: pointer;
|
font-weight: var(--font-weight-semibold); cursor: pointer;
|
||||||
transition: var(--transition-fast);
|
transition: all 0.25s var(--ease-out);
|
||||||
|
box-shadow: 0 3px 0 var(--color-primary-active),
|
||||||
|
0 4px 10px var(--glow-color-soft);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.search-wrapper button:hover { background: var(--btn-primary-bg-hover); }
|
.search-wrapper button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 0 var(--color-primary-active),
|
||||||
|
0 8px 20px var(--glow-color);
|
||||||
|
}
|
||||||
|
.search-wrapper button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 1px 0 var(--color-primary-active);
|
||||||
|
}
|
||||||
|
/* Shimmer */
|
||||||
|
.search-wrapper button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: -100%; width: 60%; height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
.search-wrapper button:hover::after { left: 120%; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
REGION BAR — Glass pills
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
/* ── Region bar ── */
|
|
||||||
.region-bar {
|
.region-bar {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--glass-bg);
|
||||||
border-bottom: 1px solid var(--color-border);
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
padding: var(--space-2) 0;
|
padding: var(--space-2) 0;
|
||||||
}
|
}
|
||||||
.region-inner {
|
.region-inner {
|
||||||
@@ -101,32 +208,52 @@
|
|||||||
.region-label {
|
.region-label {
|
||||||
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-muted); text-transform: uppercase;
|
color: var(--color-text-muted); text-transform: uppercase;
|
||||||
letter-spacing: var(--tracking-wider); margin-right: var(--space-2);
|
letter-spacing: var(--tracking-widest); margin-right: var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.region-btn {
|
.region-btn {
|
||||||
background: var(--btn-ghost-bg); border: 1px solid var(--btn-ghost-border);
|
background: transparent;
|
||||||
color: var(--btn-ghost-text); padding: var(--space-1) var(--space-3);
|
border: 1px dashed var(--glass-border);
|
||||||
border-radius: var(--radius-md); cursor: pointer; font-size: var(--text-caption);
|
color: var(--color-text-muted);
|
||||||
font-family: var(--font-body); transition: var(--transition-fast);
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-md); cursor: pointer;
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
.region-btn:hover {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
}
|
}
|
||||||
.region-btn:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
|
||||||
.region-btn.is-active {
|
.region-btn.is-active {
|
||||||
background: var(--color-primary-muted); color: var(--color-primary);
|
background: var(--color-primary-muted);
|
||||||
border-color: var(--color-primary); font-weight: var(--font-weight-semibold);
|
color: var(--color-text-accent);
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
border-style: solid;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
box-shadow: 0 0 16px var(--glow-color-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Breadcrumb ── */
|
/* ==========================================================================
|
||||||
|
BREADCRUMB
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
max-width: var(--content-xl); margin: 0 auto;
|
max-width: var(--content-xl); margin: 0 auto;
|
||||||
padding: var(--space-2) var(--space-6);
|
padding: var(--space-2) var(--space-6);
|
||||||
display: flex; flex-wrap: wrap; gap: var(--space-1);
|
display: flex; flex-wrap: wrap; gap: var(--space-1);
|
||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.breadcrumb a { color: var(--color-text-accent); }
|
.breadcrumb a { color: var(--color-text-accent); }
|
||||||
.breadcrumb .sep { margin: 0 var(--space-1); color: var(--color-text-muted); }
|
.breadcrumb .sep { margin: 0 var(--space-1); color: var(--color-text-muted); opacity: 0.4; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
MAIN CONTENT
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
/* ── Main content ── */
|
|
||||||
.main {
|
.main {
|
||||||
max-width: var(--content-xl); margin: 0 auto;
|
max-width: var(--content-xl); margin: 0 auto;
|
||||||
padding: var(--space-4) var(--space-6) var(--space-16);
|
padding: var(--space-4) var(--space-6) var(--space-16);
|
||||||
@@ -138,25 +265,44 @@
|
|||||||
letter-spacing: var(--heading-tracking-h3);
|
letter-spacing: var(--heading-tracking-h3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Grid for brands / models / years / engines / categories / groups ── */
|
/* ==========================================================================
|
||||||
|
NAV GRID — Glass cards for brands/models/years/engines/categories/groups
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.nav-grid {
|
.nav-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.nav-card {
|
.nav-card {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--color-border);
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-fast);
|
transition: all 0.3s var(--ease-out);
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* Top accent line on hover */
|
||||||
|
.nav-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gradient-accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
.nav-card:hover::before { transform: scaleX(1); }
|
||||||
.nav-card:hover {
|
.nav-card:hover {
|
||||||
border-color: var(--color-border-accent);
|
border-color: var(--color-border-accent);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 4px 20px var(--glow-color-soft);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.nav-card .name {
|
.nav-card .name {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -168,19 +314,63 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Parts list ── */
|
/* ==========================================================================
|
||||||
|
PARTS LIST — Glass rows with glow
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.parts-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
.parts-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
.part-row {
|
.part-row {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--color-border);
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
transition: var(--transition-fast);
|
transition: all 0.3s var(--ease-out);
|
||||||
}
|
}
|
||||||
.part-row:hover { border-color: var(--color-border-accent); }
|
.part-row:hover {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
box-shadow: 0 4px 20px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Local-mode priority highlights */
|
||||||
|
.part-row--tier1 {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
box-shadow: 0 0 16px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
.part-manu {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 10px; margin-bottom: var(--space-2);
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.part-row--tier2 .part-manu {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.part-manu .manu-tier { color: var(--color-primary); font-size: 13px; }
|
||||||
|
.part-oem-sub { color: var(--color-text-muted); font-weight: var(--font-weight-regular); font-size: var(--text-caption); }
|
||||||
|
|
||||||
|
.part-stock {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.part-stock--yes { background: rgba(63,185,80,0.15); color: #3FB950; border: 1px solid rgba(63,185,80,0.3); }
|
||||||
|
.part-stock--no { background: var(--color-surface-2); color: var(--color-text-muted); border: 1px dashed var(--glass-border); }
|
||||||
.part-name {
|
.part-name {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
@@ -202,18 +392,20 @@
|
|||||||
}
|
}
|
||||||
.part-alts span {
|
.part-alts span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--color-surface-2);
|
background: var(--color-primary-muted);
|
||||||
padding: 2px var(--space-2);
|
padding: 2px var(--space-2);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-right: var(--space-1);
|
margin-right: var(--space-1);
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
}
|
}
|
||||||
.part-img {
|
.part-img {
|
||||||
width: 80px; height: 80px;
|
width: 80px; height: 80px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-surface-2);
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
.part-detail-btn {
|
.part-detail-btn {
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
@@ -221,35 +413,51 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none; background: none; font-family: var(--font-body);
|
border: none; background: none; font-family: var(--font-body);
|
||||||
padding: var(--space-1) 0;
|
padding: var(--space-1) 0;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.part-detail-btn:hover { text-decoration: underline; }
|
.part-detail-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── Pagination ── */
|
/* ==========================================================================
|
||||||
|
PAGINATION — Glass buttons
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex; justify-content: center; gap: var(--space-2);
|
display: flex; justify-content: center; gap: var(--space-2);
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
}
|
}
|
||||||
.pagination button {
|
.pagination button {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background: var(--color-surface-2);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--color-border);
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
cursor: pointer; font-family: var(--font-body);
|
cursor: pointer; font-family: var(--font-body);
|
||||||
transition: var(--transition-fast);
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
.pagination button:hover {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
}
|
}
|
||||||
.pagination button:hover { border-color: var(--color-border-accent); }
|
|
||||||
.pagination button.active {
|
.pagination button.active {
|
||||||
background: var(--color-primary);
|
background: var(--gradient-accent);
|
||||||
color: var(--color-text-inverse);
|
color: var(--btn-primary-text);
|
||||||
border-color: var(--color-primary);
|
border-color: transparent;
|
||||||
|
box-shadow: 0 3px 0 var(--color-primary-active),
|
||||||
|
0 4px 12px var(--glow-color-soft);
|
||||||
}
|
}
|
||||||
.pagination button:disabled { opacity: .4; cursor: default; }
|
.pagination button:disabled { opacity: .4; cursor: default; }
|
||||||
|
|
||||||
/* ── Search results ── */
|
/* ==========================================================================
|
||||||
|
SEARCH RESULTS
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.search-results { display: flex; flex-direction: column; gap: var(--space-3); }
|
.search-results { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
|
||||||
/* ── Part detail modal ── */
|
/* ==========================================================================
|
||||||
|
PART DETAIL MODAL — Glass overlay
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
@@ -258,22 +466,28 @@
|
|||||||
justify-content: center; align-items: flex-start;
|
justify-content: center; align-items: flex-start;
|
||||||
padding: var(--space-10) var(--space-4);
|
padding: var(--space-10) var(--space-4);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.modal-overlay.open { display: flex; }
|
.modal-overlay.open { display: flex; }
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--glass-bg-strong);
|
||||||
border: 1px solid var(--color-border);
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
max-width: 700px; width: 100%;
|
max-width: 700px; width: 100%;
|
||||||
padding: var(--space-8);
|
padding: var(--space-8);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: 0 24px 48px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
.modal-close {
|
.modal-close {
|
||||||
position: absolute; top: var(--space-3); right: var(--space-3);
|
position: absolute; top: var(--space-3); right: var(--space-3);
|
||||||
background: none; border: none; color: var(--color-text-muted);
|
background: none; border: none; color: var(--color-text-muted);
|
||||||
font-size: 1.5rem; cursor: pointer; line-height: 1;
|
font-size: 1.5rem; cursor: pointer; line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.modal-close:hover { color: var(--color-text-primary); }
|
.modal-close:hover { color: var(--color-text-accent); }
|
||||||
.detail-section { margin-top: var(--space-6); }
|
.detail-section { margin-top: var(--space-6); }
|
||||||
.detail-section h4 {
|
.detail-section h4 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
@@ -290,36 +504,45 @@
|
|||||||
.alt-table th, .alt-table td {
|
.alt-table th, .alt-table td {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
.alt-table th {
|
.alt-table th {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-widest);
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading ── */
|
/* ==========================================================================
|
||||||
|
LOADING / EMPTY
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center; padding: var(--space-10);
|
text-align: center; padding: var(--space-10);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty state ── */
|
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center; padding: var(--space-10);
|
text-align: center; padding: var(--space-10);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ==========================================================================
|
||||||
|
FOOTER
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
padding: var(--space-4) 0; text-align: center;
|
padding: var(--space-4) 0; text-align: center;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
font-size: var(--text-caption); color: var(--color-text-muted);
|
font-size: var(--text-caption); color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
RESPONSIVE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.nav-grid { grid-template-columns: 1fr; }
|
.nav-grid { grid-template-columns: 1fr; }
|
||||||
.part-row { grid-template-columns: 1fr; }
|
.part-row { grid-template-columns: 1fr; }
|
||||||
@@ -332,7 +555,14 @@
|
|||||||
<div class="inner">
|
<div class="inner">
|
||||||
<a href="/" class="logo">Nexus Autoparts</a>
|
<a href="/" class="logo">Nexus Autoparts</a>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button class="theme-toggle" onclick="toggleTheme()">Tema</button>
|
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc) y marcas locales">
|
||||||
|
<button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button>
|
||||||
|
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="header-back">← Inicio</a>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
||||||
|
<span id="themeIcon">☾</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -340,11 +570,11 @@
|
|||||||
<!-- Country / Region selector -->
|
<!-- Country / Region selector -->
|
||||||
<div class="region-bar">
|
<div class="region-bar">
|
||||||
<div class="region-inner">
|
<div class="region-inner">
|
||||||
<span class="region-label">Región:</span>
|
<span class="region-label">Region:</span>
|
||||||
<button class="region-btn is-active" data-region="north-america" onclick="setRegion('north-america')">🇲🇽 México, 🇺🇸 USA, 🇨🇦 Canadá</button>
|
<button class="region-btn is-active" data-region="north-america" onclick="setRegion('north-america')">MX / USA / CA</button>
|
||||||
<button class="region-btn" data-region="europe" onclick="setRegion('europe')">🇪🇺 Europa</button>
|
<button class="region-btn" data-region="europe" onclick="setRegion('europe')">Europa</button>
|
||||||
<button class="region-btn" data-region="asia" onclick="setRegion('asia')">🇯🇵 Asia</button>
|
<button class="region-btn" data-region="asia" onclick="setRegion('asia')">Asia</button>
|
||||||
<button class="region-btn" data-region="all" onclick="setRegion('all')">🌐 Todos</button>
|
<button class="region-btn" data-region="all" onclick="setRegion('all')">Todos</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -373,6 +603,25 @@
|
|||||||
<div>© 2026 Nexus Autoparts</div>
|
<div>© 2026 Nexus Autoparts</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme toggle (inline for catalog page)
|
||||||
|
function toggleTheme() {
|
||||||
|
var html = document.documentElement;
|
||||||
|
var current = html.getAttribute('data-theme');
|
||||||
|
var next = current === 'industrial' ? 'modern' : 'industrial';
|
||||||
|
html.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('nexus-theme', next);
|
||||||
|
var icon = document.getElementById('themeIcon');
|
||||||
|
if (icon) icon.innerHTML = next === 'industrial' ? '☾' : '☀';
|
||||||
|
}
|
||||||
|
// Init icon
|
||||||
|
(function(){
|
||||||
|
var theme = document.documentElement.getAttribute('data-theme');
|
||||||
|
var icon = document.getElementById('themeIcon');
|
||||||
|
if (icon) icon.innerHTML = theme === 'industrial' ? '☾' : '☀';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script src="/catalog-public.js"></script>
|
<script src="/catalog-public.js"></script>
|
||||||
|
|
||||||
<!-- AI Chat Widget -->
|
<!-- AI Chat Widget -->
|
||||||
|
|||||||
@@ -9,18 +9,63 @@
|
|||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
var state = {
|
var state = {
|
||||||
level: 'brands', // brands | models | years | engines | categories | groups | parts | search
|
level: 'brands', // brands | models | years | engines | categories | groups | part_types | parts | search
|
||||||
brand: null, // {id, name}
|
brand: null, // {id, name}
|
||||||
model: null, // {id, name}
|
model: null, // {id, name}
|
||||||
year: null, // {id, value}
|
year: null, // {id, value}
|
||||||
engine: null, // {id_mye, name, trim}
|
engine: null, // {id_mye, name, trim}
|
||||||
|
|
||||||
|
// OEM mode (TecDoc) state — integer IDs
|
||||||
category: null, // {id, name}
|
category: null, // {id, name}
|
||||||
group: null, // {id, name}
|
group: null, // {id, name}
|
||||||
|
partType: null, // {slug, name} ← 3rd subcategory level
|
||||||
|
|
||||||
|
// Local mode (Nexpart) state — string slugs. Parallel to the OEM state
|
||||||
|
// so toggle switching mid-nav doesn't trash either branch.
|
||||||
|
nxGroup: null, // {slug, name} ← top-level Nexpart group
|
||||||
|
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
|
||||||
|
nxPartType: null, // {slug, name} ← Nexpart part type
|
||||||
|
|
||||||
region: 'north-america',
|
region: 'north-america',
|
||||||
|
mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Catalog mode toggle (OEM / Local) ──
|
||||||
|
function updateModeToggleUI() {
|
||||||
|
document.querySelectorAll('#modeToggle button').forEach(function (b) {
|
||||||
|
b.classList.toggle('is-active', b.getAttribute('data-mode') === state.mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setCatalogMode = function (mode) {
|
||||||
|
if (mode !== 'oem' && mode !== 'local') return;
|
||||||
|
if (mode === state.mode) return;
|
||||||
|
state.mode = mode;
|
||||||
|
localStorage.setItem('catalog_mode', mode);
|
||||||
|
updateModeToggleUI();
|
||||||
|
|
||||||
|
// Smart reset: if vehicle already picked, stay at categories in the new mode.
|
||||||
|
var hasVehicle = !!(state.engine && state.engine.id_mye);
|
||||||
|
|
||||||
|
// Clear category-and-below state from BOTH branches
|
||||||
|
state.category = state.group = state.partType = null;
|
||||||
|
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
|
||||||
|
state.page = 1;
|
||||||
|
|
||||||
|
if (hasVehicle) {
|
||||||
|
state.level = 'categories';
|
||||||
|
loadCategoriesForMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No vehicle — full reset back to brand selection
|
||||||
|
state.brand = state.model = state.year = state.engine = null;
|
||||||
|
state.level = 'brands';
|
||||||
|
loadBrands();
|
||||||
|
};
|
||||||
|
|
||||||
// ── Region selector (global) ──
|
// ── Region selector (global) ──
|
||||||
window.setRegion = function (region) {
|
window.setRegion = function (region) {
|
||||||
state.region = region;
|
state.region = region;
|
||||||
@@ -28,7 +73,9 @@
|
|||||||
b.classList.toggle('is-active', b.dataset.region === region);
|
b.classList.toggle('is-active', b.dataset.region === region);
|
||||||
});
|
});
|
||||||
// Reload brands with new region
|
// Reload brands with new region
|
||||||
state.brand = state.model = state.year = state.engine = state.category = state.group = null;
|
state.brand = state.model = state.year = state.engine = null;
|
||||||
|
state.category = state.group = state.partType = null;
|
||||||
|
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
|
||||||
loadBrands();
|
loadBrands();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,9 +89,10 @@
|
|||||||
var initBrandId = urlParams.get('brand');
|
var initBrandId = urlParams.get('brand');
|
||||||
|
|
||||||
// ── Init ──
|
// ── Init ──
|
||||||
|
updateModeToggleUI();
|
||||||
if (initBrandId) {
|
if (initBrandId) {
|
||||||
// Load brands, find the one matching, then navigate
|
// Load brands, find the one matching, then navigate
|
||||||
fetch(API + '/brands')
|
fetch(API + '/brands?mode=' + state.mode)
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (brands) {
|
.then(function (brands) {
|
||||||
var found = brands.find(function (b) { return b.id_brand == initBrandId; });
|
var found = brands.find(function (b) { return b.id_brand == initBrandId; });
|
||||||
@@ -62,6 +110,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enter on search
|
// Enter on search
|
||||||
|
// ── Smart search detector ──
|
||||||
|
function detectQueryType(raw) {
|
||||||
|
if (!raw) return 'keyword';
|
||||||
|
var q = raw.trim();
|
||||||
|
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
|
||||||
|
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
|
||||||
|
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
|
||||||
|
var hasLowercase = /[a-z]/.test(q);
|
||||||
|
if (hasLowercase) return 'keyword';
|
||||||
|
var tokens = q.split(/\s+/);
|
||||||
|
var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); });
|
||||||
|
if (hasYear && tokens.length > 1) return 'keyword';
|
||||||
|
var qUpper = q.toUpperCase();
|
||||||
|
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) return 'part_number';
|
||||||
|
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) return 'part_number';
|
||||||
|
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) return 'part_number';
|
||||||
|
return 'keyword';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart search hint
|
||||||
|
var searchHint = document.createElement('div');
|
||||||
|
searchHint.style.cssText = 'display:none;padding:3px 10px;font-size:12px;color:var(--color-text-accent);background:var(--color-primary-muted);border:1px dashed var(--color-border-accent);border-radius:4px;margin-top:4px;';
|
||||||
|
searchInput.parentElement.after(searchHint);
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (q.length >= 3) {
|
||||||
|
var type = detectQueryType(q);
|
||||||
|
var hints = { vin: '🚗 VIN detectado', plate: '🔖 Placa detectada', part_number: '🔩 Numero de parte', keyword: null };
|
||||||
|
if (hints[type]) { searchHint.textContent = hints[type]; searchHint.style.display = ''; }
|
||||||
|
else { searchHint.style.display = 'none'; }
|
||||||
|
} else {
|
||||||
|
searchHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('keydown', function (e) {
|
searchInput.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Enter') doSearch();
|
if (e.key === 'Enter') doSearch();
|
||||||
});
|
});
|
||||||
@@ -131,14 +215,41 @@
|
|||||||
var engineLabel = state.engine.name + (state.engine.trim ? ' (' + state.engine.trim + ')' : '');
|
var engineLabel = state.engine.name + (state.engine.trim ? ' (' + state.engine.trim + ')' : '');
|
||||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'categories\')">' + esc(engineLabel) + '</a>');
|
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'categories\')">' + esc(engineLabel) + '</a>');
|
||||||
}
|
}
|
||||||
if (state.category) {
|
// Category / subgroup / part type — rendered from EITHER the Nexpart
|
||||||
|
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch. Only one
|
||||||
|
// should be populated at any time after a navigation reset.
|
||||||
|
if (state.nxGroup) {
|
||||||
|
parts.push('<span class="sep">/</span>');
|
||||||
|
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'nx_subgroups\')">' + esc(state.nxGroup.name) + '</a>');
|
||||||
|
} else if (state.category) {
|
||||||
parts.push('<span class="sep">/</span>');
|
parts.push('<span class="sep">/</span>');
|
||||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'groups\')">' + esc(state.category.name) + '</a>');
|
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'groups\')">' + esc(state.category.name) + '</a>');
|
||||||
}
|
}
|
||||||
if (state.group) {
|
|
||||||
|
if (state.nxSubgroup) {
|
||||||
parts.push('<span class="sep">/</span>');
|
parts.push('<span class="sep">/</span>');
|
||||||
|
if (state.nxPartType) {
|
||||||
|
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'nx_part_types\')">' + esc(state.nxSubgroup.name) + '</a>');
|
||||||
|
} else {
|
||||||
|
parts.push('<span>' + esc(state.nxSubgroup.name) + '</span>');
|
||||||
|
}
|
||||||
|
} else if (state.group) {
|
||||||
|
parts.push('<span class="sep">/</span>');
|
||||||
|
if (state.partType) {
|
||||||
|
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'part_types\')">' + esc(state.group.name) + '</a>');
|
||||||
|
} else {
|
||||||
parts.push('<span>' + esc(state.group.name) + '</span>');
|
parts.push('<span>' + esc(state.group.name) + '</span>');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.nxPartType) {
|
||||||
|
parts.push('<span class="sep">/</span>');
|
||||||
|
parts.push('<span>' + esc(state.nxPartType.name) + '</span>');
|
||||||
|
} else if (state.partType) {
|
||||||
|
parts.push('<span class="sep">/</span>');
|
||||||
|
parts.push('<span>' + esc(state.partType.name) + '</span>');
|
||||||
|
}
|
||||||
|
|
||||||
if (state.level === 'search') {
|
if (state.level === 'search') {
|
||||||
parts.push('<span class="sep">/</span>');
|
parts.push('<span class="sep">/</span>');
|
||||||
parts.push('<span>Busqueda</span>');
|
parts.push('<span>Busqueda</span>');
|
||||||
@@ -147,32 +258,58 @@
|
|||||||
breadcrumbEl.innerHTML = parts.join('');
|
breadcrumbEl.innerHTML = parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global nav
|
// Helper: clears every state key at-or-below the category level, for
|
||||||
|
// BOTH the OEM branch and the Nexpart branch. Used whenever we navigate
|
||||||
|
// backward to an ancestor and need a clean slate below.
|
||||||
|
function clearCatSubtree() {
|
||||||
|
state.category = state.group = state.partType = null;
|
||||||
|
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global nav — jump to any ancestor in the breadcrumb
|
||||||
window.catalogNav = function (level) {
|
window.catalogNav = function (level) {
|
||||||
if (level === 'brands') {
|
if (level === 'brands') {
|
||||||
state.brand = state.model = state.year = state.engine = state.category = state.group = null;
|
state.brand = state.model = state.year = state.engine = null;
|
||||||
|
clearCatSubtree();
|
||||||
state.level = 'brands';
|
state.level = 'brands';
|
||||||
loadBrands();
|
loadBrands();
|
||||||
} else if (level === 'models') {
|
} else if (level === 'models') {
|
||||||
state.model = state.year = state.engine = state.category = state.group = null;
|
state.model = state.year = state.engine = null;
|
||||||
|
clearCatSubtree();
|
||||||
state.level = 'models';
|
state.level = 'models';
|
||||||
loadModels();
|
loadModels();
|
||||||
} else if (level === 'years') {
|
} else if (level === 'years') {
|
||||||
state.year = state.engine = state.category = state.group = null;
|
state.year = state.engine = null;
|
||||||
|
clearCatSubtree();
|
||||||
state.level = 'years';
|
state.level = 'years';
|
||||||
loadYears();
|
loadYears();
|
||||||
} else if (level === 'engines') {
|
} else if (level === 'engines') {
|
||||||
state.engine = state.category = state.group = null;
|
state.engine = null;
|
||||||
|
clearCatSubtree();
|
||||||
state.level = 'engines';
|
state.level = 'engines';
|
||||||
loadEngines();
|
loadEngines();
|
||||||
} else if (level === 'categories') {
|
} else if (level === 'categories') {
|
||||||
state.category = state.group = null;
|
clearCatSubtree();
|
||||||
state.level = 'categories';
|
state.level = 'categories';
|
||||||
loadCategories();
|
loadCategoriesForMode();
|
||||||
|
// OEM branch back-nav
|
||||||
} else if (level === 'groups') {
|
} else if (level === 'groups') {
|
||||||
state.group = null;
|
state.group = state.partType = null;
|
||||||
state.level = 'groups';
|
state.level = 'groups';
|
||||||
loadGroups();
|
loadGroups();
|
||||||
|
} else if (level === 'part_types') {
|
||||||
|
state.partType = null;
|
||||||
|
state.level = 'part_types';
|
||||||
|
loadPartTypes();
|
||||||
|
// Nexpart branch back-nav
|
||||||
|
} else if (level === 'nx_subgroups') {
|
||||||
|
state.nxSubgroup = state.nxPartType = null;
|
||||||
|
state.level = 'groups';
|
||||||
|
loadNexpartSubgroups();
|
||||||
|
} else if (level === 'nx_part_types') {
|
||||||
|
state.nxPartType = null;
|
||||||
|
state.level = 'part_types';
|
||||||
|
loadNexpartPartTypes();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +319,7 @@
|
|||||||
state.level = 'brands';
|
state.level = 'brands';
|
||||||
renderBreadcrumb();
|
renderBreadcrumb();
|
||||||
content.innerHTML = '<div class="loading">Cargando marcas...</div>';
|
content.innerHTML = '<div class="loading">Cargando marcas...</div>';
|
||||||
fetch(API + '/brands?region=' + (state.region || 'north-america'))
|
fetch(API + '/brands?region=' + (state.region || 'north-america') + '&mode=' + state.mode)
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (brands) {
|
.then(function (brands) {
|
||||||
var html = '<h2>Selecciona una Marca</h2><div class="nav-grid">';
|
var html = '<h2>Selecciona una Marca</h2><div class="nav-grid">';
|
||||||
@@ -274,7 +411,136 @@
|
|||||||
window.selectEngine = function (id_mye, name, trim) {
|
window.selectEngine = function (id_mye, name, trim) {
|
||||||
state.engine = { id_mye: id_mye, name: name, trim: trim };
|
state.engine = { id_mye: id_mye, name: name, trim: trim };
|
||||||
state.level = 'categories';
|
state.level = 'categories';
|
||||||
|
loadCategoriesForMode();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Mode dispatcher (OEM vs Nexpart Local) ──
|
||||||
|
function loadCategoriesForMode() {
|
||||||
|
if (state.mode === 'local') {
|
||||||
|
loadNexpartCategories();
|
||||||
|
} else {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// NEXPART (Local mode) parallel navigation
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function loadNexpartCategories() {
|
||||||
|
state.level = 'categories';
|
||||||
|
renderBreadcrumb();
|
||||||
|
content.innerHTML = '<div class="loading">Cargando categorias Local...</div>';
|
||||||
|
fetch(API + '/categories?mode=local&mye_id=' + state.engine.id_mye)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (resp) {
|
||||||
|
var cats = (resp && resp.data) || [];
|
||||||
|
if (!cats.length) {
|
||||||
|
content.innerHTML = '<h2>Categorias (Local)</h2><div class="empty">Ninguna parte de este vehiculo mapea al catalogo Local.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<h2>Categorias <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(Local · ' + cats.length + ')</span></h2>';
|
||||||
|
html += '<div class="nav-grid">';
|
||||||
|
cats.forEach(function (c) {
|
||||||
|
html += '<div class="nav-card" onclick="selectNxGroup(\'' + escAttr(c.slug) + '\',\'' + escAttr(c.name) + '\')">';
|
||||||
|
html += '<span class="name">' + esc(c.name) + '</span>';
|
||||||
|
html += '<span class="count">' + c.part_count + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
content.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () { content.innerHTML = '<div class="empty">Error cargando categorias Local.</div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectNxGroup = function (slug, name) {
|
||||||
|
state.nxGroup = { slug: slug, name: name };
|
||||||
|
state.nxSubgroup = null;
|
||||||
|
state.nxPartType = null;
|
||||||
|
state.level = 'groups';
|
||||||
|
loadNexpartSubgroups();
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadNexpartSubgroups() {
|
||||||
|
state.level = 'groups';
|
||||||
|
renderBreadcrumb();
|
||||||
|
content.innerHTML = '<div class="loading">Cargando subcategorias...</div>';
|
||||||
|
var url = API + '/groups?mode=local&mye_id=' + state.engine.id_mye
|
||||||
|
+ '&category_slug=' + encodeURIComponent(state.nxGroup.slug);
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (resp) {
|
||||||
|
var subs = (resp && resp.data) || [];
|
||||||
|
if (!subs.length) {
|
||||||
|
content.innerHTML = '<h2>' + esc(state.nxGroup.name) + '</h2><div class="empty">Sin subcategorias.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<h2>' + esc(state.nxGroup.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + subs.length + ' subcategorias)</span></h2>';
|
||||||
|
html += '<div class="nav-grid">';
|
||||||
|
subs.forEach(function (s) {
|
||||||
|
html += '<div class="nav-card" onclick="selectNxSubgroup(\'' + escAttr(s.slug) + '\',\'' + escAttr(s.name) + '\')">';
|
||||||
|
html += '<span class="name">' + esc(s.name) + '</span>';
|
||||||
|
html += '<span class="count">' + s.part_count + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
content.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () { content.innerHTML = '<div class="empty">Error cargando subcategorias.</div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectNxSubgroup = function (slug, name) {
|
||||||
|
state.nxSubgroup = { slug: slug, name: name };
|
||||||
|
state.nxPartType = null;
|
||||||
|
state.level = 'part_types';
|
||||||
|
loadNexpartPartTypes();
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadNexpartPartTypes() {
|
||||||
|
state.level = 'part_types';
|
||||||
|
renderBreadcrumb();
|
||||||
|
content.innerHTML = '<div class="loading">Cargando tipos de parte...</div>';
|
||||||
|
var url = API + '/part-types?mode=local&mye_id=' + state.engine.id_mye
|
||||||
|
+ '&group_slug=' + encodeURIComponent(state.nxGroup.slug)
|
||||||
|
+ '&subgroup_slug=' + encodeURIComponent(state.nxSubgroup.slug);
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (resp) {
|
||||||
|
var pts = (resp && resp.data) || [];
|
||||||
|
if (!pts.length) {
|
||||||
|
content.innerHTML = '<h2>' + esc(state.nxSubgroup.name) + '</h2><div class="empty">Sin tipos de parte.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single part type → auto-drill-down
|
||||||
|
if (pts.length === 1) {
|
||||||
|
state.nxPartType = { slug: pts[0].slug, name: pts[0].name };
|
||||||
|
state.level = 'parts';
|
||||||
|
state.page = 1;
|
||||||
|
loadParts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<h2>' + esc(state.nxSubgroup.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + pts.length + ' tipos)</span></h2>';
|
||||||
|
html += '<div class="nav-grid">';
|
||||||
|
pts.forEach(function (t) {
|
||||||
|
var img = t.sample_image
|
||||||
|
? '<img src="' + esc(t.sample_image) + '" alt="" style="width:24px;height:24px;object-fit:contain;margin-right:6px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
|
||||||
|
: '';
|
||||||
|
html += '<div class="nav-card" onclick="selectNxPartType(\'' + escAttr(t.slug) + '\',\'' + escAttr(t.name) + '\')">';
|
||||||
|
html += '<span class="name">' + img + esc(t.name) + '</span>';
|
||||||
|
html += '<span class="count">' + t.variant_count + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
content.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () { content.innerHTML = '<div class="empty">Error cargando tipos de parte.</div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectNxPartType = function (slug, name) {
|
||||||
|
state.nxPartType = { slug: slug, name: name };
|
||||||
|
state.level = 'parts';
|
||||||
|
state.page = 1;
|
||||||
|
loadParts();
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadCategories() {
|
function loadCategories() {
|
||||||
@@ -331,6 +597,52 @@
|
|||||||
|
|
||||||
window.selectGroup = function (id, name) {
|
window.selectGroup = function (id, name) {
|
||||||
state.group = { id: id, name: name };
|
state.group = { id: id, name: name };
|
||||||
|
state.partType = null;
|
||||||
|
state.level = 'part_types';
|
||||||
|
loadPartTypes();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Part Types (3rd subcategory level — Nexpart-style) ──
|
||||||
|
function loadPartTypes() {
|
||||||
|
state.level = 'part_types';
|
||||||
|
renderBreadcrumb();
|
||||||
|
content.innerHTML = '<div class="loading">Cargando tipos de parte...</div>';
|
||||||
|
fetch(API + '/part-types?mye_id=' + state.engine.id_mye + '&group_id=' + state.group.id)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (resp) {
|
||||||
|
var types = resp.data || [];
|
||||||
|
if (!types.length) {
|
||||||
|
// No types available — fall through to all parts in the group.
|
||||||
|
state.level = 'parts'; state.page = 1;
|
||||||
|
loadParts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (types.length === 1) {
|
||||||
|
// Single type — auto-select and show parts directly.
|
||||||
|
state.partType = { slug: types[0].slug, name: types[0].name };
|
||||||
|
state.level = 'parts'; state.page = 1;
|
||||||
|
loadParts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<h2>' + esc(state.group.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + types.length + ' tipos)</span></h2>';
|
||||||
|
html += '<div class="nav-grid">';
|
||||||
|
types.forEach(function (t) {
|
||||||
|
var img = t.sample_image
|
||||||
|
? '<img src="' + esc(t.sample_image) + '" alt="" style="width:24px;height:24px;object-fit:contain;margin-right:6px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
|
||||||
|
: '';
|
||||||
|
html += '<div class="nav-card" onclick="selectPartType(\'' + escAttr(t.slug) + '\',\'' + escAttr(t.name) + '\')">';
|
||||||
|
html += '<span class="name">' + img + esc(t.name) + '</span>';
|
||||||
|
html += '<span class="count">' + t.variant_count + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
content.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () { content.innerHTML = '<div class="empty">Error cargando tipos de parte.</div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectPartType = function (slug, name) {
|
||||||
|
state.partType = { slug: slug, name: name };
|
||||||
state.level = 'parts';
|
state.level = 'parts';
|
||||||
state.page = 1;
|
state.page = 1;
|
||||||
loadParts();
|
loadParts();
|
||||||
@@ -339,27 +651,83 @@
|
|||||||
function loadParts() {
|
function loadParts() {
|
||||||
renderBreadcrumb();
|
renderBreadcrumb();
|
||||||
content.innerHTML = '<div class="loading">Cargando partes...</div>';
|
content.innerHTML = '<div class="loading">Cargando partes...</div>';
|
||||||
var url = API + '/parts?mye_id=' + state.engine.id_mye + '&group_id=' + state.group.id + '&page=' + state.page;
|
|
||||||
|
// Build URL based on which navigation branch the user took.
|
||||||
|
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
|
||||||
|
var url;
|
||||||
|
if (state.nxGroup && state.nxSubgroup && state.nxPartType) {
|
||||||
|
url = API + '/parts?mode=local'
|
||||||
|
+ '&mye_id=' + state.engine.id_mye
|
||||||
|
+ '&page=' + state.page
|
||||||
|
+ '&nexpart_group=' + encodeURIComponent(state.nxGroup.slug)
|
||||||
|
+ '&nexpart_subgroup=' + encodeURIComponent(state.nxSubgroup.slug)
|
||||||
|
+ '&nexpart_part_type=' + encodeURIComponent(state.nxPartType.slug);
|
||||||
|
} else {
|
||||||
|
var ptParam = state.partType ? '&part_type=' + encodeURIComponent(state.partType.slug) : '';
|
||||||
|
url = API + '/parts?mye_id=' + state.engine.id_mye
|
||||||
|
+ '&group_id=' + state.group.id
|
||||||
|
+ '&page=' + state.page
|
||||||
|
+ '&mode=' + state.mode
|
||||||
|
+ ptParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header title shows the deepest selected node, regardless of branch.
|
||||||
|
var headerTitle = state.nxPartType ? state.nxPartType.name
|
||||||
|
: state.nxSubgroup ? state.nxSubgroup.name
|
||||||
|
: state.partType ? state.partType.name
|
||||||
|
: state.group ? state.group.name
|
||||||
|
: 'Partes';
|
||||||
|
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
var parts = resp.data;
|
var parts = resp.data;
|
||||||
var pag = resp.pagination;
|
var pag = resp.pagination;
|
||||||
state.totalPages = pag.total_pages;
|
state.totalPages = pag.total_pages;
|
||||||
|
var isLocal = (state.mode === 'local');
|
||||||
|
|
||||||
if (!parts.length) {
|
if (!parts.length) {
|
||||||
content.innerHTML = '<h2>' + esc(state.group.name) + '</h2><div class="empty">No se encontraron partes.</div>';
|
content.innerHTML = '<h2>' + esc(headerTitle) + '</h2><div class="empty">No se encontraron partes.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '<h2>' + esc(state.group.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + pag.total + ' partes)</span></h2>';
|
var html = '<h2>' + esc(headerTitle) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + pag.total + ' partes)</span></h2>';
|
||||||
html += '<div class="parts-list">';
|
html += '<div class="parts-list">';
|
||||||
parts.forEach(function (p) {
|
parts.forEach(function (p) {
|
||||||
html += '<div class="part-row">';
|
var tierClass = '';
|
||||||
|
if (isLocal) {
|
||||||
|
if (p.priority_tier === 1) tierClass = ' part-row--tier1';
|
||||||
|
else if (p.priority_tier === 2) tierClass = ' part-row--tier2';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="part-row' + tierClass + '">';
|
||||||
html += '<div>';
|
html += '<div>';
|
||||||
|
|
||||||
|
// Manufacturer badge (local mode only)
|
||||||
|
if (isLocal && p.manufacturer) {
|
||||||
|
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">★</span>' : '';
|
||||||
|
html += '<div class="part-manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SKU line
|
||||||
|
if (isLocal && p.part_number) {
|
||||||
|
html += '<div class="part-oem">' + esc(p.part_number) + '<span class="part-oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span></div>';
|
||||||
|
} else {
|
||||||
html += '<div class="part-oem">' + esc(p.oem_part_number) + '</div>';
|
html += '<div class="part-oem">' + esc(p.oem_part_number) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
html += '<div class="part-name">' + esc(p.name || '') + '</div>';
|
html += '<div class="part-name">' + esc(p.name || '') + '</div>';
|
||||||
if (p.description) html += '<div class="part-desc">' + esc(p.description) + '</div>';
|
if (p.description) html += '<div class="part-desc">' + esc(p.description) + '</div>';
|
||||||
|
|
||||||
|
// Stock badge (local mode)
|
||||||
|
if (isLocal) {
|
||||||
|
if (p.in_stock_network) {
|
||||||
|
html += '<div class="part-stock part-stock--yes">En stock en ' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="part-stock part-stock--no">Consultar disponibilidad</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (p.image_url) {
|
if (p.image_url) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
394
dashboard/landing.js
Normal file
394
dashboard/landing.js
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* landing.js — Pixel-Perfect inspired interactions for Nexus Autoparts
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Animated canvas grid background (grid lines, stars at intersections, glowing tiles)
|
||||||
|
* - Scroll-reveal via IntersectionObserver
|
||||||
|
* - Counter animation on stat cards
|
||||||
|
* - Typewriter effect
|
||||||
|
* - Infinite brand marquee loader
|
||||||
|
* - Theme toggle with icon swap
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// THEME TOGGLE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var themeIcon = document.getElementById('themeIcon');
|
||||||
|
|
||||||
|
function updateThemeIcon() {
|
||||||
|
var theme = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (themeIcon) themeIcon.innerHTML = theme === 'industrial' ? '☾' : '☀';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleTheme = function () {
|
||||||
|
var html = document.documentElement;
|
||||||
|
var current = html.getAttribute('data-theme');
|
||||||
|
var next = current === 'industrial' ? 'modern' : 'industrial';
|
||||||
|
html.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('nexus-theme', next);
|
||||||
|
updateThemeIcon();
|
||||||
|
// Canvas will pick up new CSS vars on next frame
|
||||||
|
};
|
||||||
|
|
||||||
|
updateThemeIcon();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// CANVAS ANIMATED GRID
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var canvas = document.getElementById('heroCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var CELL = 80;
|
||||||
|
var MAX_GLOWS = 6;
|
||||||
|
var glows = [];
|
||||||
|
var frame = 0;
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
var hero = canvas.parentElement;
|
||||||
|
canvas.width = hero.offsetWidth;
|
||||||
|
canvas.height = hero.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(varName) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStar(x, y, outer, inner, color) {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
var angle = (Math.PI / 2) * i;
|
||||||
|
ctx.lineTo(x + Math.cos(angle) * outer, y + Math.sin(angle) * outer);
|
||||||
|
ctx.lineTo(x + Math.cos(angle + Math.PI / 4) * inner, y + Math.sin(angle + Math.PI / 4) * inner);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnGlow() {
|
||||||
|
var cols = Math.floor(canvas.width / CELL);
|
||||||
|
var rows = Math.floor(canvas.height / CELL);
|
||||||
|
glows.push({
|
||||||
|
col: Math.floor(Math.random() * cols),
|
||||||
|
row: Math.floor(Math.random() * rows),
|
||||||
|
life: 0,
|
||||||
|
maxLife: 90 + Math.random() * 60, // 1.5-2.5s at 60fps
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateGrid() {
|
||||||
|
frame++;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
var gridColor = getColor('--canvas-grid-color') || 'rgba(255,255,255,0.06)';
|
||||||
|
var starColor = getColor('--canvas-star-color') || 'rgba(245,166,35,0.3)';
|
||||||
|
var glowColor = getColor('--canvas-glow-color') || 'rgba(245,166,35,0.08)';
|
||||||
|
|
||||||
|
var cols = Math.floor(canvas.width / CELL) + 1;
|
||||||
|
var rows = Math.floor(canvas.height / CELL) + 1;
|
||||||
|
|
||||||
|
// Draw grid lines
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (var c = 0; c <= cols; c++) {
|
||||||
|
var x = c * CELL;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (var r = 0; r <= rows; r++) {
|
||||||
|
var y = r * CELL;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(canvas.width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw stars at intersections (every other one for performance)
|
||||||
|
for (c = 0; c <= cols; c += 2) {
|
||||||
|
for (r = 0; r <= rows; r += 2) {
|
||||||
|
drawStar(c * CELL, r * CELL, 6, 3, starColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn glowing tiles
|
||||||
|
if (glows.length < MAX_GLOWS && Math.random() < 0.03) {
|
||||||
|
spawnGlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw glowing tiles
|
||||||
|
for (var i = glows.length - 1; i >= 0; i--) {
|
||||||
|
var g = glows[i];
|
||||||
|
g.life++;
|
||||||
|
var progress = g.life / g.maxLife;
|
||||||
|
var opacity = progress < 0.5 ? progress * 2 : (1 - progress) * 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = glowColor;
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
|
||||||
|
// Subtle shadow glow
|
||||||
|
ctx.shadowColor = glowColor;
|
||||||
|
ctx.shadowBlur = 20;
|
||||||
|
ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
if (g.life >= g.maxLife) glows.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
animateGrid();
|
||||||
|
|
||||||
|
var resizeTimeout;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(resizeCanvas, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// SCROLL REVEAL — IntersectionObserver
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var revealEls = document.querySelectorAll('.nx-reveal, .nx-reveal-scale');
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
var observer = new IntersectionObserver(function (entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });
|
||||||
|
|
||||||
|
revealEls.forEach(function (el) {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: show all immediately
|
||||||
|
revealEls.forEach(function (el) { el.classList.add('is-visible'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// COUNTER ANIMATION — Stat cards
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function animateCounter(el, target, format, suffix, duration) {
|
||||||
|
var start = 0;
|
||||||
|
var startTime = null;
|
||||||
|
|
||||||
|
function step(timestamp) {
|
||||||
|
if (!startTime) startTime = timestamp;
|
||||||
|
var progress = Math.min((timestamp - startTime) / duration, 1);
|
||||||
|
// Ease out cubic
|
||||||
|
var eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
var current = Math.floor(eased * target);
|
||||||
|
|
||||||
|
if (format.indexOf('M') !== -1) {
|
||||||
|
// e.g. "1.5M" or "15.8M"
|
||||||
|
var decimals = target >= 10e6 ? 1 : 1;
|
||||||
|
el.textContent = (current / 1e6).toFixed(decimals) + 'M' + suffix;
|
||||||
|
} else if (format.indexOf('K') !== -1) {
|
||||||
|
// e.g. "85K" or "304K"
|
||||||
|
el.textContent = Math.floor(current / 1e3) + 'K' + suffix;
|
||||||
|
} else {
|
||||||
|
el.textContent = current + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress < 1) requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger counters when stats become visible
|
||||||
|
var statCards = document.querySelectorAll('.stat-card');
|
||||||
|
if (statCards.length && 'IntersectionObserver' in window) {
|
||||||
|
var statsObserver = new IntersectionObserver(function (entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
var numEl = entry.target.querySelector('.number');
|
||||||
|
if (numEl && !numEl._animated) {
|
||||||
|
numEl._animated = true;
|
||||||
|
var target = parseInt(numEl.getAttribute('data-target'), 10) || 0;
|
||||||
|
var suffix = numEl.getAttribute('data-suffix') || '';
|
||||||
|
var format = numEl.getAttribute('data-format') || 'num';
|
||||||
|
animateCounter(numEl, target, format, suffix, 2000);
|
||||||
|
}
|
||||||
|
statsObserver.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
statCards.forEach(function (card) { statsObserver.observe(card); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// TYPEWRITER EFFECT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var typewriterEl = document.getElementById('typewriterText');
|
||||||
|
if (typewriterEl) {
|
||||||
|
var phrases = [
|
||||||
|
'POS + Inventario + CFDI 4.0 + Contabilidad',
|
||||||
|
'Catalogo TecDoc: 1.5M+ partes, 304K aftermarket',
|
||||||
|
'15.8M cross-references OEM ↔ aftermarket',
|
||||||
|
'Chatbot IA con voz, foto y diagnostico',
|
||||||
|
'WhatsApp Business integrado',
|
||||||
|
'Busca por VIN, placas o numero de parte',
|
||||||
|
'Marketplace B2B: bodegas ↔ talleres',
|
||||||
|
'PWA + Android + modo kiosko + offline',
|
||||||
|
];
|
||||||
|
var phraseIdx = 0;
|
||||||
|
var charIdx = 0;
|
||||||
|
var isDeleting = false;
|
||||||
|
var typingSpeed = 50;
|
||||||
|
|
||||||
|
function typeStep() {
|
||||||
|
var current = phrases[phraseIdx];
|
||||||
|
if (!isDeleting) {
|
||||||
|
typewriterEl.textContent = current.substring(0, charIdx + 1);
|
||||||
|
charIdx++;
|
||||||
|
if (charIdx >= current.length) {
|
||||||
|
isDeleting = true;
|
||||||
|
setTimeout(typeStep, 2000); // Pause before deleting
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(typeStep, typingSpeed);
|
||||||
|
} else {
|
||||||
|
typewriterEl.textContent = current.substring(0, charIdx);
|
||||||
|
charIdx--;
|
||||||
|
if (charIdx < 0) {
|
||||||
|
isDeleting = false;
|
||||||
|
charIdx = 0;
|
||||||
|
phraseIdx = (phraseIdx + 1) % phrases.length;
|
||||||
|
setTimeout(typeStep, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(typeStep, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start after hero reveals
|
||||||
|
setTimeout(typeStep, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// BRAND MARQUEE — Load from API + duplicate for seamless loop
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var marqueeContainer = document.getElementById('brandsMarquee');
|
||||||
|
|
||||||
|
function buildMarquee(brands) {
|
||||||
|
var html = '';
|
||||||
|
brands.forEach(function (b) {
|
||||||
|
html += '<a href="/catalog?brand=' + b.id_brand + '" class="brand-tag">' + escHtml(b.name_brand) + '</a>';
|
||||||
|
});
|
||||||
|
// Duplicate for seamless loop
|
||||||
|
marqueeContainer.innerHTML = html + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback brands in case API fails
|
||||||
|
var fallbackBrands = [
|
||||||
|
'Toyota', 'Nissan', 'Ford', 'Volkswagen', 'Honda', 'Chevrolet',
|
||||||
|
'Hyundai', 'Kia', 'Mazda', 'BMW', 'Mercedes-Benz', 'Audi',
|
||||||
|
'Renault', 'Jeep', 'Dodge', 'Ram', 'Subaru', 'Mitsubishi',
|
||||||
|
'Suzuki', 'Peugeot', 'Volvo', 'Fiat', 'Chrysler', 'Acura',
|
||||||
|
'Infiniti', 'Lexus', 'Lincoln', 'Buick', 'GMC', 'Cadillac',
|
||||||
|
'Porsche', 'Mini', 'Seat', 'Alfa Romeo', 'Land Rover', 'Jaguar'
|
||||||
|
];
|
||||||
|
|
||||||
|
fetch('/api/catalog/brands')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (brands) {
|
||||||
|
if (brands && brands.length > 0) {
|
||||||
|
buildMarquee(brands);
|
||||||
|
} else {
|
||||||
|
buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Live hero stats from API ──
|
||||||
|
// The landing has 4 stat cards with data-format tags that identify them.
|
||||||
|
// The counter animation runs on whatever data-target is set at observe
|
||||||
|
// time, so we update all 4 targets BEFORE the IntersectionObserver fires.
|
||||||
|
fetch('/api/catalog/stats')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
// Maps data-format (the card's identifier) → the JSON key to pull.
|
||||||
|
var statMap = [
|
||||||
|
{ format: '1.5M', key: 'parts' }, // OEM parts
|
||||||
|
{ format: '304K', key: 'aftermarket_parts' }, // Aftermarket
|
||||||
|
{ format: '15.8M', key: 'cross_references' }, // Cross-refs
|
||||||
|
{ format: 'num', key: 'brands' }, // Brand count
|
||||||
|
];
|
||||||
|
statMap.forEach(function (s) {
|
||||||
|
var el = document.querySelector('[data-format="' + s.format + '"]');
|
||||||
|
var value = d[s.key];
|
||||||
|
if (el && typeof value === 'number' && value > 0) {
|
||||||
|
el.setAttribute('data-target', value);
|
||||||
|
// If the animation already ran (cached, instant DOM), snap to the
|
||||||
|
// real value so users see the live number instead of the stale
|
||||||
|
// hardcoded default.
|
||||||
|
if (el._animated) {
|
||||||
|
if (s.format.indexOf('M') !== -1) {
|
||||||
|
el.textContent = (value / 1e6).toFixed(1) + 'M+';
|
||||||
|
} else if (s.format.indexOf('K') !== -1) {
|
||||||
|
el.textContent = Math.floor(value / 1e3) + 'K+';
|
||||||
|
} else {
|
||||||
|
el.textContent = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { /* fallback: hardcoded data-target values stay */ });
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// SMOOTH SCROLL for nav links
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
document.querySelectorAll('.header-nav a[href^="#"]').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = document.querySelector(a.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// HEADER — Solid on scroll
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var header = document.querySelector('.site-header');
|
||||||
|
if (header) {
|
||||||
|
window.addEventListener('scroll', function () {
|
||||||
|
if (window.scrollY > 80) {
|
||||||
|
header.style.background = 'var(--glass-bg-strong)';
|
||||||
|
} else {
|
||||||
|
header.style.background = 'var(--glass-bg)';
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -11,8 +11,9 @@ import uuid
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
_base = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
|
sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
|
||||||
|
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
|
||||||
from config import DB_URL
|
from config import DB_URL
|
||||||
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
||||||
from services.translations import translate_part_name, translate_category
|
from services.translations import translate_part_name, translate_category
|
||||||
@@ -225,6 +226,10 @@ def public_catalog():
|
|||||||
def catalog_public_js():
|
def catalog_public_js():
|
||||||
return send_from_directory('.', 'catalog-public.js')
|
return send_from_directory('.', 'catalog-public.js')
|
||||||
|
|
||||||
|
@app.route('/landing.js')
|
||||||
|
def landing_js():
|
||||||
|
return send_from_directory('.', 'landing.js')
|
||||||
|
|
||||||
@app.route('/static/<path:path>')
|
@app.route('/static/<path:path>')
|
||||||
def static_files(path):
|
def static_files(path):
|
||||||
return send_from_directory('static', path)
|
return send_from_directory('static', path)
|
||||||
@@ -372,8 +377,10 @@ NORTH_AMERICA_BRANDS = REGION_BRANDS['north-america']
|
|||||||
|
|
||||||
@app.route('/api/catalog/brands')
|
@app.route('/api/catalog/brands')
|
||||||
def api_catalog_brands():
|
def api_catalog_brands():
|
||||||
|
from services.catalog_modes import get_brands_for_mode, normalize_mode
|
||||||
region = request.args.get('region', 'north-america')
|
region = request.args.get('region', 'north-america')
|
||||||
year_id = request.args.get('year_id', type=int)
|
year_id = request.args.get('year_id', type=int)
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
params = {}
|
params = {}
|
||||||
@@ -382,7 +389,18 @@ def api_catalog_brands():
|
|||||||
year_filter = " AND mye.year_id = :year_id"
|
year_filter = " AND mye.year_id = :year_id"
|
||||||
params['year_id'] = year_id
|
params['year_id'] = year_id
|
||||||
|
|
||||||
if region == 'all':
|
# 'local' mode overrides the region filter — curated bodega list only.
|
||||||
|
if mode == 'local':
|
||||||
|
params['brands'] = list(get_brands_for_mode('local'))
|
||||||
|
rows = session.execute(text("""
|
||||||
|
SELECT DISTINCT b.id_brand, b.name_brand
|
||||||
|
FROM brands b
|
||||||
|
JOIN models m ON m.brand_id = b.id_brand
|
||||||
|
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
||||||
|
WHERE b.name_brand = ANY(:brands)""" + year_filter + """
|
||||||
|
ORDER BY b.name_brand
|
||||||
|
"""), params).mappings().all()
|
||||||
|
elif region == 'all':
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
SELECT DISTINCT b.id_brand, b.name_brand
|
SELECT DISTINCT b.id_brand, b.name_brand
|
||||||
FROM brands b
|
FROM brands b
|
||||||
@@ -472,11 +490,39 @@ def api_catalog_engines():
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _nexpart_master_conn():
|
||||||
|
"""Return a raw psycopg2 connection for passing to services.catalog_service
|
||||||
|
Nexpart functions (they expect .cursor() / .commit() / .close() DBAPI).
|
||||||
|
|
||||||
|
Uses SQLAlchemy's connection pool so we don't open a new socket per call.
|
||||||
|
"""
|
||||||
|
return engine.raw_connection()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/catalog/categories')
|
@app.route('/api/catalog/categories')
|
||||||
def api_catalog_categories():
|
def api_catalog_categories():
|
||||||
|
"""Categories for a vehicle.
|
||||||
|
|
||||||
|
OEM mode: TecDoc part_categories (integer ids).
|
||||||
|
Local mode: 14 Nexpart top-level groups filtered by what has parts for
|
||||||
|
this vehicle (strings as slugs).
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
return jsonify({'error': 'mye_id required'}), 400
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
|
||||||
|
if mode == 'local':
|
||||||
|
from services.catalog_service import get_nexpart_groups_for_vehicle
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
data = get_nexpart_groups_for_vehicle(conn, mye_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': data, 'mode': 'local'})
|
||||||
|
|
||||||
|
# OEM mode (original behavior)
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
@@ -502,10 +548,33 @@ def api_catalog_categories():
|
|||||||
|
|
||||||
@app.route('/api/catalog/groups')
|
@app.route('/api/catalog/groups')
|
||||||
def api_catalog_groups():
|
def api_catalog_groups():
|
||||||
|
"""Subgroups for a vehicle + parent category.
|
||||||
|
|
||||||
|
OEM mode: TecDoc part_groups (integer category_id).
|
||||||
|
Local mode: Nexpart subgroups within a Nexpart group (category_slug string).
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
category_id = request.args.get('category_id', type=int)
|
category_id = request.args.get('category_id', type=int)
|
||||||
if not mye_id or not category_id:
|
category_slug = request.args.get('category_slug')
|
||||||
return jsonify({'error': 'mye_id and category_id required'}), 400
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
|
||||||
|
if mode == 'local':
|
||||||
|
if not category_slug:
|
||||||
|
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||||
|
from services.catalog_service import get_nexpart_subgroups_for_vehicle
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
data = get_nexpart_subgroups_for_vehicle(conn, mye_id, category_slug)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': data, 'mode': 'local'})
|
||||||
|
|
||||||
|
# OEM mode
|
||||||
|
if not category_id:
|
||||||
|
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
@@ -526,33 +595,275 @@ def api_catalog_groups():
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/catalog/parts')
|
@app.route('/api/catalog/part-types')
|
||||||
def api_catalog_parts():
|
def api_catalog_part_types():
|
||||||
|
"""Distinct part types within a vehicle+group (3rd subcategory level).
|
||||||
|
|
||||||
|
OEM mode: distinct name_part values within a TecDoc part_group_id.
|
||||||
|
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
|
||||||
|
"""
|
||||||
|
from services.translations import translate_part_name
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
group_id = request.args.get('group_id', type=int)
|
group_id = request.args.get('group_id', type=int)
|
||||||
if not mye_id or not group_id:
|
group_slug = request.args.get('group_slug')
|
||||||
return jsonify({'error': 'mye_id and group_id required'}), 400
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
|
||||||
|
if mode == 'local':
|
||||||
|
if not group_slug or not subgroup_slug:
|
||||||
|
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||||
|
from services.catalog_service import get_nexpart_part_types_for_vehicle
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
data = get_nexpart_part_types_for_vehicle(conn, mye_id, group_slug, subgroup_slug)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': data, 'mode': 'local'})
|
||||||
|
|
||||||
|
# OEM mode
|
||||||
|
if not group_id:
|
||||||
|
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
rows = session.execute(text("""
|
||||||
|
SELECT
|
||||||
|
p.name_part AS slug,
|
||||||
|
COALESCE(p.name_es, p.name_part) AS display_name,
|
||||||
|
COUNT(*) AS variants,
|
||||||
|
(ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
|
||||||
|
GROUP BY p.name_part, COALESCE(p.name_es, p.name_part)
|
||||||
|
ORDER BY variants DESC, display_name ASC
|
||||||
|
"""), {'mye_id': mye_id, 'group_id': group_id}).mappings().all()
|
||||||
|
return jsonify({'data': [{
|
||||||
|
'slug': r['slug'],
|
||||||
|
'name': translate_part_name(r['display_name']),
|
||||||
|
'variant_count': r['variants'],
|
||||||
|
'sample_image': r['sample_image'],
|
||||||
|
} for r in rows]})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/catalog/shop-supplies/groups')
|
||||||
|
def api_shop_supplies_groups():
|
||||||
|
"""Public Shop Supplies tab: vehicle-independent groups."""
|
||||||
|
from services.catalog_service import get_shop_supplies_groups
|
||||||
|
return jsonify({'data': get_shop_supplies_groups()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/catalog/shop-supplies/subgroups')
|
||||||
|
def api_shop_supplies_subgroups():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
if not group_slug:
|
||||||
|
return jsonify({'error': 'group_slug required'}), 400
|
||||||
|
from services.catalog_service import get_shop_supplies_subgroups
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
return jsonify({'data': get_shop_supplies_subgroups(conn, group_slug)})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/catalog/shop-supplies/part-types')
|
||||||
|
def api_shop_supplies_part_types():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
|
if not group_slug or not subgroup_slug:
|
||||||
|
return jsonify({'error': 'group_slug and subgroup_slug required'}), 400
|
||||||
|
from services.catalog_service import get_shop_supplies_part_types
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
return jsonify({'data': get_shop_supplies_part_types(conn, group_slug, subgroup_slug)})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/catalog/shop-supplies/parts')
|
||||||
|
def api_shop_supplies_parts():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
|
part_type_slug = request.args.get('part_type_slug')
|
||||||
|
page = max(1, request.args.get('page', 1, type=int))
|
||||||
|
per_page = min(request.args.get('per_page', 30, type=int), 100)
|
||||||
|
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||||
|
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||||
|
from services.catalog_service import get_shop_supplies_parts
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
result = get_shop_supplies_parts(
|
||||||
|
conn, group_slug, subgroup_slug, part_type_slug,
|
||||||
|
tenant_conn=None, branch_id=None,
|
||||||
|
page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/catalog/parts')
|
||||||
|
def api_catalog_parts():
|
||||||
|
from services.catalog_modes import (
|
||||||
|
normalize_mode,
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER1,
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER2,
|
||||||
|
)
|
||||||
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
group_id = request.args.get('group_id', type=int)
|
||||||
|
part_type = request.args.get('part_type')
|
||||||
|
|
||||||
|
# Nexpart navigation slugs (Local mode, chosen via new Nexpart hierarchy)
|
||||||
|
nexpart_group = request.args.get('nexpart_group')
|
||||||
|
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||||
|
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||||
|
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
page = max(1, request.args.get('page', 1, type=int))
|
page = max(1, request.args.get('page', 1, type=int))
|
||||||
per_page = min(request.args.get('per_page', 30, type=int), 100)
|
per_page = min(request.args.get('per_page', 30, type=int), 100)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
|
||||||
|
# ─── Nexpart-nav dispatch (delegates to POS service layer) ───
|
||||||
|
# Public catalog has no tenant context — pass tenant_conn=None which
|
||||||
|
# the service gracefully handles (no local stock / price enrichment).
|
||||||
|
if mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type:
|
||||||
|
from services.catalog_service import get_parts_for_nexpart_triple
|
||||||
|
conn = _nexpart_master_conn()
|
||||||
|
try:
|
||||||
|
result = get_parts_for_nexpart_triple(
|
||||||
|
conn, mye_id,
|
||||||
|
nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||||
|
tenant_conn=None, branch_id=None,
|
||||||
|
page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Below here: legacy group_id based flow (OEM or legacy Local)
|
||||||
|
if not group_id:
|
||||||
|
return jsonify({'error': 'group_id required'}), 400
|
||||||
|
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
|
# Optional 3rd-level Part Type filter (applies to both OEM and Local modes)
|
||||||
|
pt_clause = " AND p.name_part = :part_type" if part_type else ""
|
||||||
|
|
||||||
|
if mode == 'local':
|
||||||
|
# Aftermarket-oriented listing, prioritized + stock-aware.
|
||||||
|
count_params = {'mye_id': mye_id, 'group_id': group_id}
|
||||||
|
if part_type:
|
||||||
|
count_params['part_type'] = part_type
|
||||||
total = session.execute(text("""
|
total = session.execute(text("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM vehicle_parts vp
|
FROM vehicle_parts vp
|
||||||
JOIN parts p ON p.id_part = vp.part_id
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
"""), {'mye_id': mye_id, 'group_id': group_id}).scalar()
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), count_params).scalar()
|
||||||
|
|
||||||
|
fetch_params = {
|
||||||
|
'mye_id': mye_id,
|
||||||
|
'group_id': group_id,
|
||||||
|
'tier1': list(LOCAL_PRIORITY_MANUFACTURERS_TIER1),
|
||||||
|
'tier2': list(LOCAL_PRIORITY_MANUFACTURERS_TIER2),
|
||||||
|
'limit': per_page,
|
||||||
|
'offset': offset,
|
||||||
|
}
|
||||||
|
if part_type:
|
||||||
|
fetch_params['part_type'] = part_type
|
||||||
|
|
||||||
|
rows = session.execute(text("""
|
||||||
|
WITH aftermarket_for_vehicle AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
ap.id_aftermarket_parts,
|
||||||
|
ap.oem_part_id,
|
||||||
|
ap.part_number,
|
||||||
|
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name,
|
||||||
|
m.name_manufacture,
|
||||||
|
p.oem_part_number,
|
||||||
|
COALESCE(p.name_es, p.name_part) AS oem_name,
|
||||||
|
COALESCE(p.description_es, p.description) AS oem_desc,
|
||||||
|
p.image_url AS oem_image
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """
|
||||||
|
),
|
||||||
|
stock_per_oem AS (
|
||||||
|
SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock
|
||||||
|
FROM warehouse_inventory
|
||||||
|
WHERE stock_quantity > 0
|
||||||
|
GROUP BY part_id
|
||||||
|
)
|
||||||
|
SELECT afv.id_aftermarket_parts, afv.oem_part_id, afv.part_number, afv.am_name,
|
||||||
|
afv.name_manufacture, afv.oem_part_number, afv.oem_name, afv.oem_desc, afv.oem_image,
|
||||||
|
COALESCE(s.bodega_count, 0) AS bodega_count,
|
||||||
|
s.min_price AS warehouse_price,
|
||||||
|
COALESCE(s.total_stock, 0) AS warehouse_stock,
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(afv.name_manufacture) = ANY(:tier1) THEN 1
|
||||||
|
WHEN UPPER(afv.name_manufacture) = ANY(:tier2) THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS tier
|
||||||
|
FROM aftermarket_for_vehicle afv
|
||||||
|
LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id
|
||||||
|
ORDER BY tier ASC,
|
||||||
|
(COALESCE(s.bodega_count, 0) > 0) DESC,
|
||||||
|
afv.name_manufacture ASC,
|
||||||
|
afv.am_name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""), fetch_params).mappings().all()
|
||||||
|
|
||||||
|
items = [{
|
||||||
|
'id_part': r['oem_part_id'],
|
||||||
|
'id_aftermarket': r['id_aftermarket_parts'],
|
||||||
|
'oem_part_number': r['oem_part_number'],
|
||||||
|
'part_number': r['part_number'],
|
||||||
|
'name': translate_part_name(r['am_name'] or r['oem_name']),
|
||||||
|
'description': r['oem_desc'],
|
||||||
|
'image_url': r['oem_image'],
|
||||||
|
'manufacturer': r['name_manufacture'],
|
||||||
|
'priority_tier': r['tier'],
|
||||||
|
'bodega_count': r['bodega_count'],
|
||||||
|
'warehouse_stock': r['warehouse_stock'],
|
||||||
|
'warehouse_price': float(r['warehouse_price']) if r['warehouse_price'] is not None else None,
|
||||||
|
'in_stock_network': r['bodega_count'] > 0,
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
return jsonify({'data': items, 'mode': 'local', 'pagination': {
|
||||||
|
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
|
||||||
|
}})
|
||||||
|
|
||||||
|
# OEM mode (original behavior)
|
||||||
|
oem_count_params = {'mye_id': mye_id, 'group_id': group_id}
|
||||||
|
oem_fetch_params = {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}
|
||||||
|
if part_type:
|
||||||
|
oem_count_params['part_type'] = part_type
|
||||||
|
oem_fetch_params['part_type'] = part_type
|
||||||
|
total = session.execute(text("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), oem_count_params).scalar()
|
||||||
|
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
p.description, p.description_es, p.image_url
|
p.description, p.description_es, p.image_url
|
||||||
FROM vehicle_parts vp
|
FROM vehicle_parts vp
|
||||||
JOIN parts p ON p.id_part = vp.part_id
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
|
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """
|
||||||
ORDER BY p.name_part
|
ORDER BY p.name_part
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""), {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}).mappings().all()
|
"""), oem_fetch_params).mappings().all()
|
||||||
|
|
||||||
items = [{
|
items = [{
|
||||||
'id_part': r['id_part'],
|
'id_part': r['id_part'],
|
||||||
@@ -563,7 +874,7 @@ def api_catalog_parts():
|
|||||||
} for r in rows]
|
} for r in rows]
|
||||||
|
|
||||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
return jsonify({'data': items, 'pagination': {
|
return jsonify({'data': items, 'mode': 'oem', 'pagination': {
|
||||||
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
|
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
|
||||||
}})
|
}})
|
||||||
finally:
|
finally:
|
||||||
@@ -660,6 +971,7 @@ def api_catalog_search():
|
|||||||
|
|
||||||
if is_part_number:
|
if is_part_number:
|
||||||
clean_q = q.replace(' ', '').upper()
|
clean_q = q.replace(' ', '').upper()
|
||||||
|
# Search OEM part numbers first
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
p.image_url
|
p.image_url
|
||||||
@@ -668,6 +980,28 @@ def api_catalog_search():
|
|||||||
ORDER BY p.oem_part_number
|
ORDER BY p.oem_part_number
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
|
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
|
||||||
|
|
||||||
|
# If no OEM match, search aftermarket + cross-reference numbers
|
||||||
|
if not rows:
|
||||||
|
rows = session.execute(text("""
|
||||||
|
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
|
p.image_url
|
||||||
|
FROM parts p
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
WHERE REPLACE(UPPER(ap.part_number), ' ', '') LIKE :q
|
||||||
|
ORDER BY p.oem_part_number
|
||||||
|
LIMIT :limit
|
||||||
|
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
|
||||||
|
if not rows:
|
||||||
|
rows = session.execute(text("""
|
||||||
|
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
|
p.image_url
|
||||||
|
FROM parts p
|
||||||
|
JOIN part_cross_references cr ON cr.part_id = p.id_part
|
||||||
|
WHERE REPLACE(UPPER(cr.cross_reference_number), ' ', '') LIKE :q
|
||||||
|
ORDER BY p.oem_part_number
|
||||||
|
LIMIT :limit
|
||||||
|
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
|
||||||
else:
|
else:
|
||||||
tsquery = ' & '.join(q.split())
|
tsquery = ' & '.join(q.split())
|
||||||
rows = session.execute(text("""
|
rows = session.execute(text("""
|
||||||
@@ -723,19 +1057,46 @@ def api_catalog_search():
|
|||||||
|
|
||||||
@app.route('/api/catalog/stats')
|
@app.route('/api/catalog/stats')
|
||||||
def api_catalog_stats():
|
def api_catalog_stats():
|
||||||
|
"""Public stats endpoint consumed by the landing page hero cards.
|
||||||
|
|
||||||
|
Returns live counts from the master catalog so the landing never shows
|
||||||
|
stale numbers. Counts are fast (pg_class reltuples for the big tables)
|
||||||
|
and cached in-process for 10 minutes.
|
||||||
|
"""
|
||||||
|
# In-process cache — counts barely change and are called on every
|
||||||
|
# landing pageview; no reason to hit the DB every time.
|
||||||
|
import time as _t
|
||||||
|
now = _t.time()
|
||||||
|
cache = getattr(api_catalog_stats, '_cache', None)
|
||||||
|
if cache and cache[0] > now:
|
||||||
|
return jsonify(cache[1])
|
||||||
|
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
|
# Use reltuples for large tables (parts, aftermarket_parts,
|
||||||
|
# part_cross_references) — exact COUNT(*) on 1M+ row tables is slow
|
||||||
|
# and the landing doesn't need exact accuracy, just "big numbers".
|
||||||
row = session.execute(text("""
|
row = session.execute(text("""
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM brands) AS brands,
|
(SELECT COUNT(*) FROM brands) AS brands,
|
||||||
(SELECT COUNT(*) FROM models) AS models,
|
(SELECT COUNT(*) FROM models) AS models,
|
||||||
(SELECT COUNT(*) FROM model_year_engine) AS vehicles,
|
(SELECT COUNT(*) FROM model_year_engine) AS vehicles,
|
||||||
(SELECT COUNT(*) FROM parts) AS parts
|
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'parts') AS parts,
|
||||||
|
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'aftermarket_parts') AS aftermarket_parts,
|
||||||
|
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'part_cross_references') AS cross_references,
|
||||||
|
(SELECT COUNT(*) FROM manufacturers) AS manufacturers
|
||||||
""")).mappings().first()
|
""")).mappings().first()
|
||||||
return jsonify({
|
data = {
|
||||||
'brands': row['brands'], 'models': row['models'],
|
'brands': row['brands'] or 0,
|
||||||
'vehicles': row['vehicles'], 'parts': row['parts']
|
'models': row['models'] or 0,
|
||||||
})
|
'vehicles': row['vehicles'] or 0,
|
||||||
|
'parts': row['parts'] or 0,
|
||||||
|
'aftermarket_parts': row['aftermarket_parts'] or 0,
|
||||||
|
'cross_references': row['cross_references'] or 0,
|
||||||
|
'manufacturers': row['manufacturers'] or 0,
|
||||||
|
}
|
||||||
|
api_catalog_stats._cache = (now + 600, data)
|
||||||
|
return jsonify(data)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@@ -3581,9 +3942,13 @@ def api_pos_search_parts():
|
|||||||
# Store Dashboard Endpoints
|
# Store Dashboard Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Old page routes removed (demo, bodega, pitch, login, tienda)
|
# Old page routes removed (demo, bodega, login, tienda)
|
||||||
# APIs below are kept for backward compatibility
|
# APIs below are kept for backward compatibility
|
||||||
|
|
||||||
|
@app.route('/pitch')
|
||||||
|
def pitch_deck():
|
||||||
|
return send_from_directory(os.path.join(_base, '..', 'pitch'), 'deck.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/tienda/stats')
|
@app.route('/api/tienda/stats')
|
||||||
def api_tienda_stats():
|
def api_tienda_stats():
|
||||||
|
|||||||
@@ -558,6 +558,153 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
GLASSMORPHISM TOKENS
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
[data-theme="industrial"] {
|
||||||
|
--glass-bg: rgba(26, 26, 26, 0.70);
|
||||||
|
--glass-bg-strong: rgba(26, 26, 26, 0.85);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
--glass-highlight: rgba(245, 166, 35, 0.06);
|
||||||
|
|
||||||
|
--glow-color: rgba(245, 166, 35, 0.40);
|
||||||
|
--glow-color-soft: rgba(245, 166, 35, 0.15);
|
||||||
|
--glow-color-strong: rgba(245, 166, 35, 0.60);
|
||||||
|
|
||||||
|
--gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%);
|
||||||
|
--gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%);
|
||||||
|
|
||||||
|
--canvas-grid-color: rgba(255, 255, 255, 0.06);
|
||||||
|
--canvas-star-color: rgba(245, 166, 35, 0.30);
|
||||||
|
--canvas-glow-color: rgba(245, 166, 35, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="modern"] {
|
||||||
|
--glass-bg: rgba(248, 249, 255, 0.70);
|
||||||
|
--glass-bg-strong: rgba(248, 249, 255, 0.85);
|
||||||
|
--glass-border: rgba(26, 26, 46, 0.08);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
--glass-highlight: rgba(255, 107, 53, 0.04);
|
||||||
|
|
||||||
|
--glow-color: rgba(255, 107, 53, 0.35);
|
||||||
|
--glow-color-soft: rgba(255, 107, 53, 0.12);
|
||||||
|
--glow-color-strong: rgba(255, 107, 53, 0.55);
|
||||||
|
|
||||||
|
--gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%);
|
||||||
|
--gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%);
|
||||||
|
|
||||||
|
--canvas-grid-color: rgba(26, 26, 46, 0.05);
|
||||||
|
--canvas-star-color: rgba(255, 107, 53, 0.20);
|
||||||
|
--canvas-glow-color: rgba(255, 107, 53, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
ANIMATION KEYFRAMES
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@keyframes nx-fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-marquee {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-glow-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); }
|
||||||
|
50% { box-shadow: 0 0 40px var(--glow-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-border-glow {
|
||||||
|
0%, 100% { border-color: var(--color-border); }
|
||||||
|
50% { border-color: var(--color-border-accent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-typewriter-cursor {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SCROLL REVEAL UTILITIES
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nx-reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nx-reveal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nx-reveal-scale {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nx-reveal-scale.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children */
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
GLASS UTILITIES
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nx-glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nx-glass-strong {
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
END OF TOKENS FILE
|
END OF TOKENS FILE
|
||||||
nexus-autoparts-design/tokens/tokens.css
|
nexus-autoparts-design/tokens/tokens.css
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ def create_app():
|
|||||||
from blueprints.marketplace_bp import marketplace_bp
|
from blueprints.marketplace_bp import marketplace_bp
|
||||||
app.register_blueprint(marketplace_bp)
|
app.register_blueprint(marketplace_bp)
|
||||||
|
|
||||||
|
from blueprints.peer_bp import peer_bp
|
||||||
|
app.register_blueprint(peer_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -112,6 +115,10 @@ def create_app():
|
|||||||
def pos_fleet():
|
def pos_fleet():
|
||||||
return render_template('fleet.html')
|
return render_template('fleet.html')
|
||||||
|
|
||||||
|
@app.route('/pos/quotations')
|
||||||
|
def pos_quotations():
|
||||||
|
return render_template('quotations.html')
|
||||||
|
|
||||||
@app.route('/pos/whatsapp')
|
@app.route('/pos/whatsapp')
|
||||||
def pos_whatsapp():
|
def pos_whatsapp():
|
||||||
return render_template('whatsapp.html')
|
return render_template('whatsapp.html')
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ def _master_only(fn):
|
|||||||
@catalog_bp.route('/brands', methods=['GET'])
|
@catalog_bp.route('/brands', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def brands():
|
def brands():
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
year_id = request.args.get('year_id', type=int)
|
year_id = request.args.get('year_id', type=int)
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
def _do(master):
|
def _do(master):
|
||||||
data = catalog_service.get_brands(master, year_id=year_id)
|
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,41 +127,191 @@ def engines():
|
|||||||
@catalog_bp.route('/categories', methods=['GET'])
|
@catalog_bp.route('/categories', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def categories():
|
def categories():
|
||||||
|
"""Categories for a vehicle.
|
||||||
|
|
||||||
|
OEM mode: TecDoc part_categories (id_part_category, name).
|
||||||
|
Local mode: 14 Nexpart top-level groups, filtered by what's available
|
||||||
|
for this vehicle. Returns 'slug' (string) instead of integer id.
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
if not mye_id:
|
if not mye_id:
|
||||||
return jsonify({'error': 'mye_id required'}), 400
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
def _do(master):
|
def _do(master):
|
||||||
|
if mode == 'local':
|
||||||
|
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||||
|
else:
|
||||||
data = catalog_service.get_categories(master, mye_id)
|
data = catalog_service.get_categories(master, mye_id)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/groups', methods=['GET'])
|
@catalog_bp.route('/groups', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def groups():
|
def groups():
|
||||||
|
"""Subgroups for a vehicle + parent category.
|
||||||
|
|
||||||
|
OEM mode: TecDoc part_groups within a TecDoc part_category (integer ids).
|
||||||
|
Local mode: Nexpart subgroups within a Nexpart group (string slugs).
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
category_id = request.args.get('category_id', type=int)
|
category_id = request.args.get('category_id', type=int)
|
||||||
if not mye_id or not category_id:
|
category_slug = request.args.get('category_slug')
|
||||||
return jsonify({'error': 'mye_id and category_id required'}), 400
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
def _do(master):
|
def _do(master):
|
||||||
|
if mode == 'local':
|
||||||
|
if not category_slug:
|
||||||
|
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||||
|
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
|
||||||
|
else:
|
||||||
|
if not category_id:
|
||||||
|
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||||
return jsonify({'data': data})
|
return jsonify({'data': data, 'mode': mode})
|
||||||
return _master_only(_do)
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
# ─── Parts with stock enrichment (master + tenant) ───
|
# ─── Parts with stock enrichment (master + tenant) ───
|
||||||
|
|
||||||
|
@catalog_bp.route('/part-types', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def part_types():
|
||||||
|
"""Distinct part types (3rd subcategory level) for a vehicle + group/subgroup.
|
||||||
|
|
||||||
|
OEM mode: distinct name_part values within a TecDoc part_group_id.
|
||||||
|
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
|
group_id = request.args.get('group_id', type=int)
|
||||||
|
group_slug = request.args.get('group_slug') # parent Nexpart group
|
||||||
|
subgroup_slug = request.args.get('subgroup_slug') # current Nexpart subgroup
|
||||||
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
def _do(master):
|
||||||
|
if mode == 'local':
|
||||||
|
if not group_slug or not subgroup_slug:
|
||||||
|
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||||
|
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
||||||
|
master, mye_id, group_slug, subgroup_slug
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not group_id:
|
||||||
|
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||||
|
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||||
|
return jsonify({'data': data, 'mode': mode})
|
||||||
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def shop_supplies_groups():
|
||||||
|
"""Vehicle-independent groups (Chemicals + Tires/Tools)."""
|
||||||
|
def _do(master):
|
||||||
|
data = catalog_service.get_shop_supplies_groups()
|
||||||
|
return jsonify({'data': data})
|
||||||
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/shop-supplies/subgroups', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def shop_supplies_subgroups():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
if not group_slug:
|
||||||
|
return jsonify({'error': 'group_slug required'}), 400
|
||||||
|
def _do(master):
|
||||||
|
data = catalog_service.get_shop_supplies_subgroups(master, group_slug)
|
||||||
|
return jsonify({'data': data})
|
||||||
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/shop-supplies/part-types', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def shop_supplies_part_types():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
|
if not group_slug or not subgroup_slug:
|
||||||
|
return jsonify({'error': 'group_slug and subgroup_slug required'}), 400
|
||||||
|
def _do(master):
|
||||||
|
data = catalog_service.get_shop_supplies_part_types(master, group_slug, subgroup_slug)
|
||||||
|
return jsonify({'data': data})
|
||||||
|
return _master_only(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/shop-supplies/parts', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def shop_supplies_parts():
|
||||||
|
group_slug = request.args.get('group_slug')
|
||||||
|
subgroup_slug = request.args.get('subgroup_slug')
|
||||||
|
part_type_slug = request.args.get('part_type_slug')
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 30, type=int)
|
||||||
|
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||||
|
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||||
|
def _do(master, tenant, branch_id):
|
||||||
|
result = catalog_service.get_shop_supplies_parts(
|
||||||
|
master, group_slug, subgroup_slug, part_type_slug,
|
||||||
|
tenant, branch_id, page, per_page,
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/parts', methods=['GET'])
|
@catalog_bp.route('/parts', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def parts():
|
def parts():
|
||||||
|
"""Parts list for the deepest navigation level.
|
||||||
|
|
||||||
|
Three call shapes (the endpoint chooses based on which params are present):
|
||||||
|
|
||||||
|
A) OEM mode legacy:
|
||||||
|
?mode=oem&mye_id=&group_id=&part_type=...
|
||||||
|
B) Local mode legacy (TecDoc-style):
|
||||||
|
?mode=local&mye_id=&group_id=&part_type=...
|
||||||
|
C) Local mode Nexpart navigation (NEW):
|
||||||
|
?mode=local&mye_id=&nexpart_group=&nexpart_subgroup=&nexpart_part_type=
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import normalize_mode
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
group_id = request.args.get('group_id', type=int)
|
group_id = request.args.get('group_id', type=int)
|
||||||
|
part_type = request.args.get('part_type') # optional 3rd-level (legacy)
|
||||||
|
|
||||||
|
# Nexpart navigation slugs (Local mode only)
|
||||||
|
nexpart_group = request.args.get('nexpart_group')
|
||||||
|
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||||
|
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||||
|
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = request.args.get('per_page', 30, type=int)
|
per_page = request.args.get('per_page', 30, type=int)
|
||||||
if not mye_id or not group_id:
|
mode = normalize_mode(request.args.get('mode'))
|
||||||
return jsonify({'error': 'mye_id and group_id required'}), 400
|
|
||||||
|
if not mye_id:
|
||||||
|
return jsonify({'error': 'mye_id required'}), 400
|
||||||
|
|
||||||
|
use_nexpart_nav = mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type
|
||||||
|
|
||||||
|
if not use_nexpart_nav and not group_id:
|
||||||
|
return jsonify({'error': 'group_id (or nexpart_group + subgroup + part_type) required'}), 400
|
||||||
|
|
||||||
def _do(master, tenant, branch_id):
|
def _do(master, tenant, branch_id):
|
||||||
result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page)
|
if use_nexpart_nav:
|
||||||
|
result = catalog_service.get_parts_for_nexpart_triple(
|
||||||
|
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||||
|
tenant, branch_id, page, per_page,
|
||||||
|
)
|
||||||
|
elif mode == 'local':
|
||||||
|
result = catalog_service.get_parts_local(
|
||||||
|
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = catalog_service.get_parts(
|
||||||
|
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||||
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
return _with_conns(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,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('/employees/<int:emp_id>', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_employee(emp_id):
|
||||||
|
"""Update an existing employee's name, email, role, branch, discount, active status.
|
||||||
|
If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged."""
|
||||||
|
import bcrypt
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Check employee exists
|
||||||
|
cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Employee not found'}), 404
|
||||||
|
|
||||||
|
# Build SET clause dynamically — only update provided fields
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
field_map = {
|
||||||
|
'name': 'name', 'email': 'email', 'phone': 'phone',
|
||||||
|
'role': 'role', 'branch_id': 'branch_id',
|
||||||
|
'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active',
|
||||||
|
}
|
||||||
|
for json_key, col in field_map.items():
|
||||||
|
if json_key in data:
|
||||||
|
updates.append(f"{col} = %s")
|
||||||
|
params.append(data[json_key])
|
||||||
|
|
||||||
|
# PIN update (only if provided and non-empty)
|
||||||
|
if data.get('pin') and len(str(data['pin'])) >= 4:
|
||||||
|
pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode()
|
||||||
|
updates.append("pin = %s")
|
||||||
|
params.append(pin_hash)
|
||||||
|
updates.append("password_hash = %s")
|
||||||
|
params.append(pin_hash)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Nothing to update'}), 400
|
||||||
|
|
||||||
|
params.append(emp_id)
|
||||||
|
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
||||||
|
|
||||||
|
from services.audit import log_action
|
||||||
|
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||||
|
new_value={k: v for k, v in data.items() if k != 'pin'})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True, 'message': 'Employee updated'})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/currency', methods=['GET'])
|
@config_bp.route('/currency', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_currency():
|
def get_currency():
|
||||||
@@ -244,6 +299,42 @@ def get_business():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/business', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_business():
|
||||||
|
"""Save tenant business info to tenant_config."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
field_map = {
|
||||||
|
'razon_social': 'tenant_razon_social',
|
||||||
|
'nombre': 'tenant_nombre',
|
||||||
|
'rfc': 'tenant_rfc',
|
||||||
|
'regimen_fiscal': 'tenant_regimen_fiscal',
|
||||||
|
'direccion': 'tenant_direccion',
|
||||||
|
'telefono': 'tenant_telefono',
|
||||||
|
'email': 'tenant_email',
|
||||||
|
# Tax params
|
||||||
|
'tax_iva': 'tax_iva',
|
||||||
|
'tax_ieps': 'tax_ieps',
|
||||||
|
'invoice_serie': 'invoice_serie',
|
||||||
|
'invoice_folio': 'invoice_folio',
|
||||||
|
'default_currency': 'default_currency',
|
||||||
|
'default_payment_method': 'default_payment_method',
|
||||||
|
}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
for field, key in field_map.items():
|
||||||
|
val = data.get(field)
|
||||||
|
if val is not None:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (key, str(val).strip()))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/theme', methods=['GET'])
|
@config_bp.route('/theme', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_theme():
|
def get_theme():
|
||||||
|
|||||||
@@ -1,360 +1,336 @@
|
|||||||
# /home/Autopartes/pos/blueprints/marketplace_bp.py
|
"""
|
||||||
"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order."""
|
Nexus Marketplace B2B — REST endpoints (Phase 1).
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
Bodegas
|
||||||
|
GET /pos/api/marketplace/bodegas list verified bodegas
|
||||||
|
GET /pos/api/marketplace/bodegas/<id> bodega detail
|
||||||
|
|
||||||
|
Inventory
|
||||||
|
POST /pos/api/marketplace/inventory/upload bulk CSV upload (seller)
|
||||||
|
GET /pos/api/marketplace/inventory/search browse (text/brand/city filters)
|
||||||
|
GET /pos/api/marketplace/inventory/part/<id> bodegas stocking this part
|
||||||
|
|
||||||
|
Purchase Orders
|
||||||
|
POST /pos/api/marketplace/orders create draft
|
||||||
|
GET /pos/api/marketplace/orders/mine buyer's PO list
|
||||||
|
GET /pos/api/marketplace/orders/inbox seller's incoming PO list
|
||||||
|
GET /pos/api/marketplace/orders/<id> full detail
|
||||||
|
POST /pos/api/marketplace/orders/<id>/transition state change
|
||||||
|
|
||||||
|
NOTE: this replaces an earlier stub that referenced now-unused tables
|
||||||
|
(marketplace_orders, marketplace_order_items, tenants.is_seller flag).
|
||||||
|
The Phase 1 schema uses bodegas + purchase_orders + purchase_order_items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_master_conn, get_tenant_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
|
from services import marketplace_service as mkt
|
||||||
|
|
||||||
|
|
||||||
marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace')
|
marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace')
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/sellers', methods=['GET'])
|
# ─── Role loader + checker ────────────────────────────────────────────────
|
||||||
@require_auth()
|
|
||||||
def list_sellers():
|
def _load_marketplace_profile():
|
||||||
"""List active sellers/bodegas."""
|
"""Fetch the caller's marketplace_role + bodega_id from the tenant DB
|
||||||
conn = get_master_conn()
|
and attach to flask.g. Call AFTER @require_auth. Idempotent."""
|
||||||
|
if hasattr(g, 'marketplace_loaded'):
|
||||||
|
return
|
||||||
|
g.marketplace_role = 'buyer'
|
||||||
|
g.marketplace_bodega_id = None
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
SELECT id, name, subdomain, rfc
|
"SELECT marketplace_role, bodega_id FROM employees WHERE id = %s",
|
||||||
FROM tenants
|
(g.employee_id,),
|
||||||
WHERE is_active = true AND is_seller = true
|
)
|
||||||
ORDER BY name
|
row = cur.fetchone()
|
||||||
""")
|
if row:
|
||||||
sellers = []
|
g.marketplace_role = row[0] or 'buyer'
|
||||||
for r in cur.fetchall():
|
g.marketplace_bodega_id = row[1]
|
||||||
sellers.append({'id': r[0], 'name': r[1], 'subdomain': r[2], 'rfc': r[3]})
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': sellers})
|
except Exception as e:
|
||||||
|
print(f'[marketplace] failed to load role: {e}')
|
||||||
|
g.marketplace_loaded = True
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/search', methods=['GET'])
|
def require_marketplace_role(*allowed_roles):
|
||||||
|
"""Decorator: only allow users whose marketplace_role is in the allowed list.
|
||||||
|
Must be applied AFTER @require_auth()."""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
_load_marketplace_profile()
|
||||||
|
if g.marketplace_role not in allowed_roles:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Marketplace role {g.marketplace_role} cannot access this endpoint',
|
||||||
|
'required': list(allowed_roles),
|
||||||
|
}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _with_master(f):
|
||||||
|
"""Open a master connection, run f(master_conn), always close."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
try:
|
||||||
|
return f(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# BODEGAS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_bp.route('/whoami', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def whoami():
|
||||||
|
"""Return the current user's marketplace profile (role, bodega_id, etc.)."""
|
||||||
|
_load_marketplace_profile()
|
||||||
|
return jsonify({
|
||||||
|
'employee_id': g.employee_id,
|
||||||
|
'employee_name': g.employee_name,
|
||||||
|
'tenant_id': g.tenant_id,
|
||||||
|
'marketplace_role': g.marketplace_role,
|
||||||
|
'bodega_id': g.marketplace_bodega_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/bodegas', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_bodegas():
|
||||||
|
verified_only = request.args.get('verified_only', 'true').lower() != 'false'
|
||||||
|
city = request.args.get('city')
|
||||||
|
def _do(master):
|
||||||
|
return jsonify({'data': mkt.list_bodegas(master, verified_only=verified_only, city=city)})
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/bodegas/<int:bodega_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_bodega(bodega_id):
|
||||||
|
def _do(master):
|
||||||
|
b = mkt.get_bodega(master, bodega_id)
|
||||||
|
if not b:
|
||||||
|
return jsonify({'error': 'Bodega not found'}), 404
|
||||||
|
return jsonify(b)
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# INVENTORY
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_bp.route('/inventory/upload', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
@require_marketplace_role('seller', 'admin')
|
||||||
|
def upload_inventory():
|
||||||
|
"""CSV bulk upload for a bodega's warehouse inventory.
|
||||||
|
|
||||||
|
Body options:
|
||||||
|
multipart/form-data with file field 'file'
|
||||||
|
OR
|
||||||
|
application/json with {bodega_id, csv} (admin override)
|
||||||
|
"""
|
||||||
|
# Sellers upload to THEIR bodega; admin can upload to any.
|
||||||
|
if g.marketplace_role == 'seller':
|
||||||
|
bodega_id = g.marketplace_bodega_id
|
||||||
|
if not bodega_id:
|
||||||
|
return jsonify({'error': 'Seller has no bodega_id assigned'}), 400
|
||||||
|
else:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
bodega_id = int(body.get('bodega_id') or 0)
|
||||||
|
if not bodega_id:
|
||||||
|
return jsonify({'error': 'bodega_id required for admin upload'}), 400
|
||||||
|
|
||||||
|
# Read CSV from either multipart file or JSON body
|
||||||
|
csv_text = None
|
||||||
|
if 'file' in request.files:
|
||||||
|
csv_text = request.files['file'].read().decode('utf-8', errors='ignore')
|
||||||
|
else:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
csv_text = body.get('csv')
|
||||||
|
if not csv_text:
|
||||||
|
return jsonify({'error': 'CSV payload required (file upload or JSON csv field)'}), 400
|
||||||
|
|
||||||
|
def _do(master):
|
||||||
|
result = mkt.upload_inventory_csv(master, bodega_id, csv_text)
|
||||||
|
return jsonify(result)
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/inventory/search', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def search_inventory():
|
def search_inventory():
|
||||||
"""Search across ALL seller tenant inventories.
|
q = request.args.get('q')
|
||||||
|
brand = request.args.get('brand')
|
||||||
Query params:
|
city = request.args.get('city')
|
||||||
q: search term (required, min 2 chars)
|
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||||
seller_id: optional filter by specific seller
|
def _do(master):
|
||||||
page: page number (default 1)
|
data = mkt.search_inventory(master, query=q, brand=brand, city=city, limit=limit)
|
||||||
per_page: results per page (default 50, max 200)
|
return jsonify({'data': data, 'count': len(data)})
|
||||||
"""
|
return _with_master(_do)
|
||||||
q = request.args.get('q', '').strip()
|
|
||||||
if len(q) < 2:
|
|
||||||
return jsonify({'error': 'Search query must be at least 2 characters'}), 400
|
|
||||||
|
|
||||||
seller_id = request.args.get('seller_id')
|
|
||||||
page = int(request.args.get('page', 1))
|
|
||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
|
|
||||||
# Get all seller tenants
|
|
||||||
master = get_master_conn()
|
|
||||||
mcur = master.cursor()
|
|
||||||
|
|
||||||
if seller_id:
|
|
||||||
mcur.execute("""
|
|
||||||
SELECT id, name, db_name FROM tenants
|
|
||||||
WHERE is_active = true AND is_seller = true AND id = %s
|
|
||||||
""", (seller_id,))
|
|
||||||
else:
|
|
||||||
mcur.execute("""
|
|
||||||
SELECT id, name, db_name FROM tenants
|
|
||||||
WHERE is_active = true AND is_seller = true
|
|
||||||
ORDER BY name
|
|
||||||
""")
|
|
||||||
|
|
||||||
sellers = mcur.fetchall()
|
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
|
|
||||||
results = []
|
|
||||||
search_pattern = f'%{q}%'
|
|
||||||
|
|
||||||
for s_id, s_name, db_name in sellers:
|
|
||||||
try:
|
|
||||||
conn = get_tenant_conn(s_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, i.unit,
|
|
||||||
COALESCE(s.stock, 0) AS stock
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE i.is_active = true
|
|
||||||
AND COALESCE(s.stock, 0) > 0
|
|
||||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)
|
|
||||||
ORDER BY i.name
|
|
||||||
LIMIT %s
|
|
||||||
""", (search_pattern, search_pattern, search_pattern, per_page))
|
|
||||||
|
|
||||||
for r in cur.fetchall():
|
|
||||||
results.append({
|
|
||||||
'seller_id': s_id,
|
|
||||||
'seller_name': s_name,
|
|
||||||
'part_number': r[0],
|
|
||||||
'name': r[1],
|
|
||||||
'brand': r[2],
|
|
||||||
'price': float(r[3]) if r[3] else 0,
|
|
||||||
'tax_rate': float(r[4]) if r[4] else 0.16,
|
|
||||||
'unit': r[5] or 'PZA',
|
|
||||||
'stock': r[6],
|
|
||||||
})
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
# Skip tenants with connection issues
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort all results by name, then paginate
|
|
||||||
results.sort(key=lambda x: x['name'])
|
|
||||||
total = len(results)
|
|
||||||
paged = results[offset:offset + per_page]
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'data': paged,
|
|
||||||
'pagination': {
|
|
||||||
'page': page,
|
|
||||||
'per_page': per_page,
|
|
||||||
'total': total,
|
|
||||||
'pages': (total + per_page - 1) // per_page if per_page else 1,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/order', methods=['POST'])
|
@marketplace_bp.route('/inventory/part/<int:part_id>', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
|
def bodegas_with_part(part_id):
|
||||||
|
def _do(master):
|
||||||
|
data = mkt.get_bodegas_with_part(master, part_id)
|
||||||
|
return jsonify({'data': data, 'count': len(data)})
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# PURCHASE ORDERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@marketplace_bp.route('/orders', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
@require_marketplace_role('buyer', 'admin')
|
||||||
def create_order():
|
def create_order():
|
||||||
"""Create a marketplace order from buyer to seller.
|
"""Create a new PO in draft status.
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
seller_id: int (required)
|
{
|
||||||
items: [{ part_number, part_name, quantity, unit_price }] (required)
|
"bodega_id": 1,
|
||||||
notes: str (optional)
|
"items": [{"part_id": 123, "quantity": 2, "unit_price": 150}, ...],
|
||||||
"""
|
"delivery_method": "pickup",
|
||||||
data = request.get_json() or {}
|
"delivery_address": "...",
|
||||||
seller_id = data.get('seller_id')
|
"buyer_notes": "..."
|
||||||
items = data.get('items', [])
|
|
||||||
|
|
||||||
if not seller_id:
|
|
||||||
return jsonify({'error': 'seller_id required'}), 400
|
|
||||||
if not items:
|
|
||||||
return jsonify({'error': 'items required (non-empty array)'}), 400
|
|
||||||
|
|
||||||
buyer_id = g.tenant_id
|
|
||||||
|
|
||||||
# Get buyer and seller names
|
|
||||||
master = get_master_conn()
|
|
||||||
mcur = master.cursor()
|
|
||||||
mcur.execute("SELECT name FROM tenants WHERE id = %s", (buyer_id,))
|
|
||||||
buyer_row = mcur.fetchone()
|
|
||||||
mcur.execute("SELECT name FROM tenants WHERE id = %s AND is_seller = true AND is_active = true", (seller_id,))
|
|
||||||
seller_row = mcur.fetchone()
|
|
||||||
mcur.close()
|
|
||||||
|
|
||||||
if not buyer_row:
|
|
||||||
master.close()
|
|
||||||
return jsonify({'error': 'Buyer tenant not found'}), 404
|
|
||||||
if not seller_row:
|
|
||||||
master.close()
|
|
||||||
return jsonify({'error': 'Seller not found or not active'}), 404
|
|
||||||
|
|
||||||
buyer_name = buyer_row[0]
|
|
||||||
seller_name = seller_row[0]
|
|
||||||
|
|
||||||
# Calculate total
|
|
||||||
total = 0
|
|
||||||
for item in items:
|
|
||||||
qty = item.get('quantity', 0)
|
|
||||||
price = item.get('unit_price', 0)
|
|
||||||
item['subtotal'] = round(qty * price, 2)
|
|
||||||
total += item['subtotal']
|
|
||||||
|
|
||||||
mcur2 = master.cursor()
|
|
||||||
mcur2.execute("""
|
|
||||||
INSERT INTO marketplace_orders (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, notes)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id
|
|
||||||
""", (buyer_id, seller_id, buyer_name, seller_name, round(total, 2), data.get('notes')))
|
|
||||||
order_id = mcur2.fetchone()[0]
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
mcur2.execute("""
|
|
||||||
INSERT INTO marketplace_order_items (order_id, part_number, part_name, quantity, unit_price, subtotal)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
""", (order_id, item.get('part_number'), item.get('part_name'),
|
|
||||||
item.get('quantity', 0), item.get('unit_price', 0), item.get('subtotal', 0)))
|
|
||||||
|
|
||||||
master.commit()
|
|
||||||
mcur2.close()
|
|
||||||
master.close()
|
|
||||||
|
|
||||||
return jsonify({'id': order_id, 'total': round(total, 2), 'message': 'Order created'}), 201
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/orders', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def list_orders():
|
|
||||||
"""List marketplace orders (as buyer or seller).
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
role: 'buyer' or 'seller' (default: both)
|
|
||||||
status: filter by status
|
|
||||||
page: page number
|
|
||||||
per_page: results per page
|
|
||||||
"""
|
|
||||||
tenant_id = g.tenant_id
|
|
||||||
role = request.args.get('role', '')
|
|
||||||
status = request.args.get('status', '')
|
|
||||||
page = int(request.args.get('page', 1))
|
|
||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
|
|
||||||
master = get_master_conn()
|
|
||||||
mcur = master.cursor()
|
|
||||||
|
|
||||||
where_clauses = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if role == 'buyer':
|
|
||||||
where_clauses.append("buyer_tenant_id = %s")
|
|
||||||
params.append(tenant_id)
|
|
||||||
elif role == 'seller':
|
|
||||||
where_clauses.append("seller_tenant_id = %s")
|
|
||||||
params.append(tenant_id)
|
|
||||||
else:
|
|
||||||
where_clauses.append("(buyer_tenant_id = %s OR seller_tenant_id = %s)")
|
|
||||||
params.extend([tenant_id, tenant_id])
|
|
||||||
|
|
||||||
if status:
|
|
||||||
where_clauses.append("status = %s")
|
|
||||||
params.append(status)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
mcur.execute(f"SELECT count(*) FROM marketplace_orders WHERE {where}", params)
|
|
||||||
total = mcur.fetchone()[0]
|
|
||||||
|
|
||||||
mcur.execute(f"""
|
|
||||||
SELECT id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
|
|
||||||
total, status, notes, created_at, updated_at
|
|
||||||
FROM marketplace_orders
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""", params + [per_page, offset])
|
|
||||||
|
|
||||||
orders = []
|
|
||||||
for r in mcur.fetchall():
|
|
||||||
orders.append({
|
|
||||||
'id': r[0], 'buyer_tenant_id': r[1], 'seller_tenant_id': r[2],
|
|
||||||
'buyer_name': r[3], 'seller_name': r[4],
|
|
||||||
'total': float(r[5]) if r[5] else 0,
|
|
||||||
'status': r[6], 'notes': r[7],
|
|
||||||
'created_at': str(r[8]), 'updated_at': str(r[9]),
|
|
||||||
})
|
|
||||||
|
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'data': orders,
|
|
||||||
'pagination': {
|
|
||||||
'page': page, 'per_page': per_page,
|
|
||||||
'total': total, 'pages': (total + per_page - 1) // per_page if per_page else 1,
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
|
||||||
@require_auth()
|
|
||||||
def update_order_status(order_id):
|
|
||||||
"""Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending.
|
|
||||||
|
|
||||||
Body:
|
|
||||||
status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
|
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
body = request.get_json() or {}
|
||||||
new_status = data.get('status')
|
bodega_id = int(body.get('bodega_id') or 0)
|
||||||
valid_statuses = ['confirmed', 'shipped', 'delivered', 'cancelled']
|
items = body.get('items') or []
|
||||||
if new_status not in valid_statuses:
|
|
||||||
return jsonify({'error': f'status must be one of: {", ".join(valid_statuses)}'}), 400
|
|
||||||
|
|
||||||
tenant_id = g.tenant_id
|
if not bodega_id:
|
||||||
|
return jsonify({'error': 'bodega_id required'}), 400
|
||||||
|
if not items:
|
||||||
|
return jsonify({'error': 'At least one item required'}), 400
|
||||||
|
|
||||||
master = get_master_conn()
|
def _do(master):
|
||||||
mcur = master.cursor()
|
try:
|
||||||
mcur.execute("""
|
po_id = mkt.create_po_draft(
|
||||||
SELECT buyer_tenant_id, seller_tenant_id, status
|
master,
|
||||||
FROM marketplace_orders WHERE id = %s
|
buyer_tenant_id=g.tenant_id,
|
||||||
""", (order_id,))
|
buyer_user_id=g.employee_id,
|
||||||
row = mcur.fetchone()
|
buyer_display_name=g.employee_name,
|
||||||
|
buyer_phone=body.get('buyer_phone'),
|
||||||
if not row:
|
buyer_email=body.get('buyer_email'),
|
||||||
mcur.close()
|
bodega_id=bodega_id,
|
||||||
master.close()
|
items=items,
|
||||||
return jsonify({'error': 'Order not found'}), 404
|
delivery_method=body.get('delivery_method', 'pickup'),
|
||||||
|
delivery_address=body.get('delivery_address'),
|
||||||
buyer_id, seller_id, current_status = row
|
buyer_notes=body.get('buyer_notes'),
|
||||||
|
)
|
||||||
# Permission check
|
return jsonify({'ok': True, 'po_id': po_id}), 201
|
||||||
if tenant_id == buyer_id:
|
except ValueError as e:
|
||||||
# Buyer can only cancel pending orders
|
return jsonify({'error': str(e)}), 400
|
||||||
if new_status != 'cancelled' or current_status != 'pending':
|
return _with_master(_do)
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
return jsonify({'error': 'Buyer can only cancel pending orders'}), 403
|
|
||||||
elif tenant_id == seller_id:
|
|
||||||
# Seller can do any transition
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
return jsonify({'error': 'Not authorized for this order'}), 403
|
|
||||||
|
|
||||||
mcur.execute("""
|
|
||||||
UPDATE marketplace_orders SET status = %s, updated_at = NOW()
|
|
||||||
WHERE id = %s
|
|
||||||
""", (new_status, order_id))
|
|
||||||
master.commit()
|
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
|
|
||||||
return jsonify({'id': order_id, 'status': new_status, 'message': 'Order updated'})
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_bp.route('/orders/<int:order_id>/items', methods=['GET'])
|
@marketplace_bp.route('/orders/mine', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_order_items(order_id):
|
def my_orders():
|
||||||
"""Get items for a specific order."""
|
"""Buyer view: POs this tenant (or user) created."""
|
||||||
tenant_id = g.tenant_id
|
only_mine = request.args.get('only_mine', 'true').lower() != 'false'
|
||||||
|
def _do(master):
|
||||||
|
data = mkt.list_pos_for_buyer(
|
||||||
|
master,
|
||||||
|
buyer_tenant_id=g.tenant_id,
|
||||||
|
buyer_user_id=g.employee_id if only_mine else None,
|
||||||
|
)
|
||||||
|
return jsonify({'data': data, 'count': len(data)})
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
master = get_master_conn()
|
|
||||||
mcur = master.cursor()
|
|
||||||
|
|
||||||
# Verify tenant is buyer or seller
|
@marketplace_bp.route('/orders/inbox', methods=['GET'])
|
||||||
mcur.execute("""
|
@require_auth()
|
||||||
SELECT buyer_tenant_id, seller_tenant_id FROM marketplace_orders WHERE id = %s
|
@require_marketplace_role('seller', 'admin')
|
||||||
""", (order_id,))
|
def seller_inbox():
|
||||||
row = mcur.fetchone()
|
"""Seller view: incoming POs for this bodega."""
|
||||||
if not row or (row[0] != tenant_id and row[1] != tenant_id):
|
if g.marketplace_role == 'seller':
|
||||||
mcur.close()
|
bodega_id = g.marketplace_bodega_id
|
||||||
master.close()
|
else:
|
||||||
|
bodega_id = int(request.args.get('bodega_id') or 0)
|
||||||
|
if not bodega_id:
|
||||||
|
return jsonify({'error': 'bodega_id required'}), 400
|
||||||
|
def _do(master):
|
||||||
|
data = mkt.list_pos_for_seller(master, bodega_id)
|
||||||
|
return jsonify({'data': data, 'count': len(data)})
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/orders/<int:po_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_order(po_id):
|
||||||
|
"""PO detail — buyer sees their tenant's POs, seller sees their bodega's."""
|
||||||
|
_load_marketplace_profile()
|
||||||
|
def _do(master):
|
||||||
|
po = mkt.get_po_detail(master, po_id)
|
||||||
|
if not po:
|
||||||
|
return jsonify({'error': 'PO not found'}), 404
|
||||||
|
|
||||||
|
# Authorization
|
||||||
|
if g.marketplace_role == 'seller':
|
||||||
|
if po['bodega_id'] != g.marketplace_bodega_id:
|
||||||
|
return jsonify({'error': 'Not authorized'}), 403
|
||||||
|
elif g.marketplace_role == 'buyer':
|
||||||
|
if po['buyer_tenant_id'] != g.tenant_id:
|
||||||
|
return jsonify({'error': 'Not authorized'}), 403
|
||||||
|
# admin sees all
|
||||||
|
|
||||||
|
return jsonify(po)
|
||||||
|
return _with_master(_do)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/orders/<int:po_id>/transition', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def transition_order(po_id):
|
||||||
|
"""Change a PO's status. Role determines which transitions are allowed.
|
||||||
|
|
||||||
|
Body: {"new_status": "confirmed", "note": "optional note"}
|
||||||
|
"""
|
||||||
|
_load_marketplace_profile()
|
||||||
|
body = request.get_json() or {}
|
||||||
|
new_status = body.get('new_status')
|
||||||
|
note = body.get('note')
|
||||||
|
if not new_status:
|
||||||
|
return jsonify({'error': 'new_status required'}), 400
|
||||||
|
|
||||||
|
# Map marketplace_role to actor_kind for the state machine.
|
||||||
|
actor_kind = g.marketplace_role
|
||||||
|
if actor_kind == 'admin':
|
||||||
|
actor_kind = 'seller' # admin defaults to seller path in Phase 1
|
||||||
|
|
||||||
|
def _do(master):
|
||||||
|
po = mkt.get_po_detail(master, po_id)
|
||||||
|
if not po:
|
||||||
|
return jsonify({'error': 'PO not found'}), 404
|
||||||
|
if g.marketplace_role == 'seller' and po['bodega_id'] != g.marketplace_bodega_id:
|
||||||
|
return jsonify({'error': 'Not authorized'}), 403
|
||||||
|
if g.marketplace_role == 'buyer' and po['buyer_tenant_id'] != g.tenant_id:
|
||||||
return jsonify({'error': 'Not authorized'}), 403
|
return jsonify({'error': 'Not authorized'}), 403
|
||||||
|
|
||||||
mcur.execute("""
|
result = mkt.transition_po(
|
||||||
SELECT id, part_number, part_name, quantity, unit_price, subtotal
|
master,
|
||||||
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
|
po_id=po_id,
|
||||||
""", (order_id,))
|
new_status=new_status,
|
||||||
|
actor_user_id=g.employee_id,
|
||||||
items = []
|
actor_kind=actor_kind,
|
||||||
for r in mcur.fetchall():
|
note=note,
|
||||||
items.append({
|
)
|
||||||
'id': r[0], 'part_number': r[1], 'part_name': r[2],
|
if not result.get('ok'):
|
||||||
'quantity': r[3], 'unit_price': float(r[4]) if r[4] else 0,
|
return jsonify(result), 400
|
||||||
'subtotal': float(r[5]) if r[5] else 0,
|
return jsonify(result)
|
||||||
})
|
return _with_master(_do)
|
||||||
mcur.close()
|
|
||||||
master.close()
|
|
||||||
return jsonify({'data': items})
|
|
||||||
|
|||||||
95
pos/blueprints/peer_bp.py
Normal file
95
pos/blueprints/peer_bp.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Peer API — public endpoints for inter-instance communication.
|
||||||
|
|
||||||
|
These endpoints do NOT require auth (they're called machine-to-machine by
|
||||||
|
other Nexus instances on the network). They expose read-only inventory data
|
||||||
|
so the marketplace can aggregate stock across the whole Nexus network.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /pos/api/peer/health — instance status + inventory count
|
||||||
|
GET /pos/api/peer/inventory — search this instance's inventory
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
from services import peer_service
|
||||||
|
|
||||||
|
peer_bp = Blueprint('peer', __name__, url_prefix='/pos/api/peer')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Which tenant to use for the peer endpoint? ──────────────────────────
|
||||||
|
# In production each instance serves one tenant. For the demo, we hardcode
|
||||||
|
# tenant_id=11 (the demo refaccionaria). This will be read from a config
|
||||||
|
# file in the future when each instance has exactly 1 active tenant.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
def _get_local_tenant_id():
|
||||||
|
"""Read the local tenant ID from peers.json or fall back to 11."""
|
||||||
|
try:
|
||||||
|
cfg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
|
||||||
|
with open(cfg_path, 'r') as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
return cfg.get('tenant_id', 11)
|
||||||
|
except Exception:
|
||||||
|
return 11
|
||||||
|
|
||||||
|
|
||||||
|
@peer_bp.route('/health', methods=['GET'])
|
||||||
|
def peer_health():
|
||||||
|
"""Public health check — no auth. Returns instance name + basic stats."""
|
||||||
|
tenant_id = _get_local_tenant_id()
|
||||||
|
inventory_count = 0
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM inventory i
|
||||||
|
WHERE i.is_active = TRUE
|
||||||
|
AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) > 0
|
||||||
|
""")
|
||||||
|
inventory_count = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[peer] health check DB error: {e}')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'instance_name': peer_service.get_instance_name(),
|
||||||
|
'instance_id': peer_service.get_instance_id(),
|
||||||
|
'inventory_count': inventory_count,
|
||||||
|
'peer_count': len(peer_service.get_peers()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@peer_bp.route('/inventory', methods=['GET'])
|
||||||
|
def peer_inventory():
|
||||||
|
"""Public inventory search — no auth.
|
||||||
|
|
||||||
|
Called by other Nexus instances to see what this refaccionaria has in stock.
|
||||||
|
Returns minimal data: part number, name, brand, price, stock hint.
|
||||||
|
Does NOT expose exact stock quantities (competitive info).
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
q: search term (optional — without it, returns popular/all items)
|
||||||
|
limit: max results (default 50, max 200)
|
||||||
|
"""
|
||||||
|
q = request.args.get('q', '').strip() or None
|
||||||
|
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||||
|
|
||||||
|
tenant_id = _get_local_tenant_id()
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(tenant_id)
|
||||||
|
data = peer_service.get_local_inventory(conn, query=q, limit=limit)
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[peer] inventory query error: {e}')
|
||||||
|
data = []
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'instance_name': peer_service.get_instance_name(),
|
||||||
|
'data': data,
|
||||||
|
'count': len(data),
|
||||||
|
})
|
||||||
@@ -505,7 +505,8 @@ def list_quotations():
|
|||||||
where_clauses.append("q.status = %s")
|
where_clauses.append("q.status = %s")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if g.branch_id:
|
if g.branch_id:
|
||||||
where_clauses.append("q.branch_id = %s")
|
# Show both this branch's quotes AND branchless ones (e.g. WhatsApp)
|
||||||
|
where_clauses.append("(q.branch_id = %s OR q.branch_id IS NULL)")
|
||||||
params.append(g.branch_id)
|
params.append(g.branch_id)
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
@@ -515,7 +516,7 @@ def list_quotations():
|
|||||||
|
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
|
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
|
||||||
q.total, q.status, q.valid_until, q.created_at,
|
q.total, q.status, q.valid_until, q.created_at, q.notes,
|
||||||
c.name as customer_name, e.name as employee_name
|
c.name as customer_name, e.name as employee_name
|
||||||
FROM quotations q
|
FROM quotations q
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
@@ -527,6 +528,9 @@ def list_quotations():
|
|||||||
|
|
||||||
quotations = []
|
quotations = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
|
notes = r[9] or ''
|
||||||
|
source = 'whatsapp' if notes.startswith('WA:') else 'pos'
|
||||||
|
wa_phone = notes.replace('WA:', '') if source == 'whatsapp' else None
|
||||||
quotations.append({
|
quotations.append({
|
||||||
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
|
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
|
||||||
'subtotal': float(r[3]) if r[3] else 0,
|
'subtotal': float(r[3]) if r[3] else 0,
|
||||||
@@ -534,7 +538,9 @@ def list_quotations():
|
|||||||
'total': float(r[5]) if r[5] else 0,
|
'total': float(r[5]) if r[5] else 0,
|
||||||
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
|
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
|
||||||
'created_at': str(r[8]),
|
'created_at': str(r[8]),
|
||||||
'customer_name': r[9], 'employee_name': r[10],
|
'customer_name': r[10], 'employee_name': r[11],
|
||||||
|
'source': source,
|
||||||
|
'wa_phone': wa_phone,
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -546,6 +552,146 @@ def list_quotations():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def delete_quotation(quot_id):
|
||||||
|
"""Delete a quotation and its items."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||||
|
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if deleted == 0:
|
||||||
|
return jsonify({'error': 'Cotización no encontrada'}), 404
|
||||||
|
return jsonify({'ok': True, 'deleted_id': quot_id})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/quotations/<int:quot_id>/print', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def print_quotation_ticket(quot_id):
|
||||||
|
"""Generate a printable ticket for a quotation (ESC/POS or browser)."""
|
||||||
|
from flask import Response
|
||||||
|
from services.thermal_printer import generate_quotation_ticket
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
printer_type = body.get('printer_type', 'escpos_raw')
|
||||||
|
width = int(body.get('width', 80))
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||||
|
q.created_at, q.notes, c.name as customer_name
|
||||||
|
FROM quotations q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = %s
|
||||||
|
""", (quot_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Quotation not found'}), 404
|
||||||
|
|
||||||
|
notes = row[6] or ''
|
||||||
|
wa_phone = notes.replace('WA:', '') if notes.startswith('WA:') else None
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT part_number, name, quantity, unit_price, subtotal
|
||||||
|
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||||
|
""", (quot_id,))
|
||||||
|
items = [{'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||||
|
'unit_price': float(r[3]) if r[3] else 0,
|
||||||
|
'subtotal': float(r[4]) if r[4] else 0} for r in cur.fetchall()]
|
||||||
|
|
||||||
|
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'")
|
||||||
|
for rw in cur.fetchall():
|
||||||
|
if rw[0] == 'tenant_nombre': business_info['name'] = rw[1]
|
||||||
|
elif rw[0] == 'tenant_rfc': business_info['rfc'] = rw[1]
|
||||||
|
elif rw[0] == 'tenant_direccion': business_info['address'] = rw[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
quote_data = {
|
||||||
|
'id': row[0],
|
||||||
|
'subtotal': float(row[1]) if row[1] else 0,
|
||||||
|
'tax_total': float(row[2]) if row[2] else 0,
|
||||||
|
'total': float(row[3]) if row[3] else 0,
|
||||||
|
'valid_until': str(row[4]) if row[4] else None,
|
||||||
|
'created_at': str(row[5]) if row[5] else '',
|
||||||
|
'customer_name': row[7] or '',
|
||||||
|
'wa_phone': wa_phone,
|
||||||
|
'items': items,
|
||||||
|
}
|
||||||
|
|
||||||
|
if printer_type == 'browser':
|
||||||
|
return jsonify(quote_data)
|
||||||
|
|
||||||
|
raw = generate_quotation_ticket(quote_data, business_info, width=width)
|
||||||
|
return Response(raw, mimetype='application/octet-stream',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename=cotizacion_{quot_id}.bin'})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/quotations/print-queue', methods=['GET'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def quotation_print_queue():
|
||||||
|
"""Return quotations that were confirmed via WhatsApp and haven't been
|
||||||
|
printed yet. The POS browser polls this endpoint and auto-prints.
|
||||||
|
|
||||||
|
Returns: {data: [{id, total, customer_name, wa_phone, confirmed_at}]}
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT q.id, q.total, q.notes, q.created_at,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM quotations q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.status = 'converted'
|
||||||
|
AND q.notes LIKE 'WA:%%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tenant_config
|
||||||
|
WHERE key = 'printed_quote_' || q.id::text
|
||||||
|
)
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
data = []
|
||||||
|
for r in rows:
|
||||||
|
notes = r[2] or ''
|
||||||
|
data.append({
|
||||||
|
'id': r[0],
|
||||||
|
'total': float(r[1]) if r[1] else 0,
|
||||||
|
'wa_phone': notes.replace('WA:', '') if notes.startswith('WA:') else None,
|
||||||
|
'created_at': str(r[3]) if r[3] else '',
|
||||||
|
'customer_name': r[4] or '',
|
||||||
|
})
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'data': data})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/quotations/<int:quot_id>/mark-printed', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def mark_quotation_printed(quot_id):
|
||||||
|
"""Mark a quotation as printed so it doesn't appear in the print queue again."""
|
||||||
|
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
|
||||||
|
""", (f'printed_quote_{quot_id}', 'true'))
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
|
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_quotation(quot_id):
|
def get_quotation(quot_id):
|
||||||
|
|||||||
@@ -19,6 +19,133 @@ from services import whatsapp_service
|
|||||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||||
|
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||||||
|
quotation system to know what to add when the user says "cotizar".
|
||||||
|
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
|
||||||
|
"""
|
||||||
|
if not tenant_conn:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Translate common English search terms to Spanish for local inventory
|
||||||
|
# (the AI sends search_query in English, but local inventory names
|
||||||
|
# are often in Spanish)
|
||||||
|
from services.translations import PART_TRANSLATIONS
|
||||||
|
search_terms = [search_query]
|
||||||
|
# Add the Spanish translation if we have one
|
||||||
|
for en, es in PART_TRANSLATIONS.items():
|
||||||
|
if en.upper() in search_query.upper():
|
||||||
|
search_terms.append(es)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build ILIKE conditions for all search terms
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
for term in search_terms:
|
||||||
|
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||||
|
like = f'%{term}%'
|
||||||
|
params.extend([like, like, like])
|
||||||
|
|
||||||
|
where_search = ' OR '.join(conditions)
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||||
|
COALESCE(s.stock, 0) AS stock,
|
||||||
|
i.unit, i.location
|
||||||
|
FROM inventory i
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT inventory_id, SUM(quantity) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
GROUP BY inventory_id
|
||||||
|
) s ON s.inventory_id = i.id
|
||||||
|
WHERE i.is_active = TRUE
|
||||||
|
AND ({where_search})
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(s.stock, 0) > 0 DESC,
|
||||||
|
i.name
|
||||||
|
LIMIT 10
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
||||||
|
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
||||||
|
|
||||||
|
# Split into in-stock and out-of-stock
|
||||||
|
in_stock = [r for r in rows if r[6] > 0]
|
||||||
|
out_stock = [r for r in rows if r[6] <= 0]
|
||||||
|
|
||||||
|
# Build the first-part dict for quotation tracking
|
||||||
|
# Use the first in-stock part, or first out-of-stock if none available
|
||||||
|
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||||||
|
first_part = None
|
||||||
|
if best:
|
||||||
|
first_part = {
|
||||||
|
'inventory_id': None, # we'd need the id — fetch it
|
||||||
|
'part_number': best[0],
|
||||||
|
'name': best[1],
|
||||||
|
'brand': best[2] or '',
|
||||||
|
'price': float(best[3]) if best[3] else 0,
|
||||||
|
'tax_rate': 0.16,
|
||||||
|
'stock': best[6],
|
||||||
|
'unit': best[7] or 'PZA',
|
||||||
|
}
|
||||||
|
# Fetch the inventory ID for the quotation item FK
|
||||||
|
try:
|
||||||
|
cur2 = tenant_conn.cursor()
|
||||||
|
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
|
||||||
|
(best[0],))
|
||||||
|
inv_row = cur2.fetchone()
|
||||||
|
if inv_row:
|
||||||
|
first_part['inventory_id'] = inv_row[0]
|
||||||
|
cur2.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if in_stock:
|
||||||
|
lines.append('✅ *Tenemos en stock:*')
|
||||||
|
lines.append('')
|
||||||
|
for r in in_stock:
|
||||||
|
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||||
|
brand_str = f'*{brand}*' if brand else ''
|
||||||
|
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||||||
|
lines.append(f' • {brand_str} {name}')
|
||||||
|
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||||||
|
lines.append('')
|
||||||
|
else:
|
||||||
|
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||||||
|
lines.append('')
|
||||||
|
for r in out_stock[:5]:
|
||||||
|
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||||
|
brand_str = f'*{brand}*' if brand else ''
|
||||||
|
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||||||
|
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
|
||||||
|
|
||||||
|
# Vehicle context
|
||||||
|
if vehicle and vehicle.get('brand'):
|
||||||
|
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
|
||||||
|
lines.append(f'🚗 Vehículo: {v_str.strip()}')
|
||||||
|
|
||||||
|
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
|
||||||
|
|
||||||
|
return '\n'.join(lines), first_part
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] Enrichment error: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/status', methods=['GET'])
|
@whatsapp_bp.route('/status', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def status():
|
def status():
|
||||||
@@ -45,7 +172,14 @@ def logout():
|
|||||||
|
|
||||||
@whatsapp_bp.route('/webhook', methods=['POST'])
|
@whatsapp_bp.route('/webhook', methods=['POST'])
|
||||||
def webhook():
|
def webhook():
|
||||||
"""Receive messages from Baileys bridge (public, no auth)."""
|
"""Receive messages from Baileys bridge (public, no auth).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
||||||
|
2. Build inventory context for the AI (what this tenant has in stock).
|
||||||
|
3. Ask the chatbot for a reply, enriched with that context.
|
||||||
|
4. Send the reply back via the Baileys bridge.
|
||||||
|
"""
|
||||||
data = request.get_json(force=True, silent=True) or {}
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
|
||||||
if data.get('event') != 'messages.upsert':
|
if data.get('event') != 'messages.upsert':
|
||||||
@@ -55,30 +189,205 @@ def webhook():
|
|||||||
if not msg.get('phone') or msg.get('from_me'):
|
if not msg.get('phone') or msg.get('from_me'):
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
# Save to DB if tenant connection available
|
# Reuse one tenant connection for the whole webhook path — we need it
|
||||||
|
# for persistence AND for the inventory-context lookup.
|
||||||
|
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||||||
|
tenant_id = 11
|
||||||
|
tenant_conn = None
|
||||||
|
inventory_context = None
|
||||||
try:
|
try:
|
||||||
# Try to get a tenant connection (use default tenant for webhook)
|
tenant_conn = get_tenant_conn(tenant_id)
|
||||||
conn = get_tenant_conn(11) # TODO: resolve tenant from phone number
|
|
||||||
cur = conn.cursor()
|
# 1. Log the incoming message (with contact display name)
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id)
|
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||||||
VALUES (%s, 'incoming', %s, %s)
|
VALUES (%s, 'incoming', %s, %s, %s)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
""", (msg['phone'], msg['text'], msg['message_id']))
|
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||||||
conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
|
# 2. Build inventory context once per webhook call so the chatbot
|
||||||
|
# can say things like "tengo 5 Bosch BP-123 por $450".
|
||||||
|
try:
|
||||||
|
from services.ai_chat import get_inventory_context
|
||||||
|
inventory_context = get_inventory_context(tenant_conn)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] inventory_context failed: {e}")
|
||||||
|
inventory_context = None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] tenant connection failed: {e}")
|
||||||
|
|
||||||
|
# 3. Dispatch by media kind + quotation commands
|
||||||
|
reply = None
|
||||||
|
reply_to = msg.get('jid') or msg['phone']
|
||||||
|
media_kind = msg.get('media_kind', 'text')
|
||||||
|
clean_phone = msg.get('phone', '')
|
||||||
|
|
||||||
|
# ── Check for quotation commands FIRST (before AI) ──
|
||||||
|
if media_kind == 'text' and msg.get('text'):
|
||||||
|
from services.wa_quotation import (
|
||||||
|
detect_quote_intent, get_open_quotation, create_quotation,
|
||||||
|
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||||||
|
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||||||
|
)
|
||||||
|
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
||||||
|
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
||||||
|
|
||||||
|
if intent == 'add':
|
||||||
|
last_part = get_last_shown_part(clean_phone)
|
||||||
|
if not last_part:
|
||||||
|
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||||||
|
elif tenant_conn:
|
||||||
|
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||||
|
if not qid:
|
||||||
|
qid = create_quotation(tenant_conn, clean_phone)
|
||||||
|
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
||||||
|
detail = get_quotation_detail(tenant_conn, qid)
|
||||||
|
item_count = len(detail['items']) if detail else 0
|
||||||
|
reply = (
|
||||||
|
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
||||||
|
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
||||||
|
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif intent == 'send':
|
||||||
|
if tenant_conn:
|
||||||
|
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||||
|
if qid:
|
||||||
|
detail = get_quotation_detail(tenant_conn, qid)
|
||||||
|
reply = format_quotation_wa(detail)
|
||||||
|
if not reply:
|
||||||
|
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||||||
|
else:
|
||||||
|
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||||||
|
|
||||||
|
elif intent == 'clear':
|
||||||
|
if tenant_conn:
|
||||||
|
clear_quotation(tenant_conn, clean_phone)
|
||||||
|
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
||||||
|
|
||||||
|
elif intent == 'confirm':
|
||||||
|
if tenant_conn:
|
||||||
|
qid = confirm_quotation(tenant_conn, clean_phone)
|
||||||
|
if qid:
|
||||||
|
reply = (
|
||||||
|
f'✅ *Pedido confirmado!*\n\n'
|
||||||
|
f'Tu cotización #{qid} fue registrada.\n'
|
||||||
|
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
||||||
|
f'¡Gracias por tu compra! 🙏'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||||||
|
|
||||||
|
if intent is not None:
|
||||||
|
# It was a quote command — send reply and skip the AI
|
||||||
|
if reply:
|
||||||
|
result = whatsapp_service.send_message(reply_to, reply)
|
||||||
|
if tenant_conn:
|
||||||
|
try:
|
||||||
|
cur_save = tenant_conn.cursor()
|
||||||
|
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur_save.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Clean up and return early
|
||||||
|
if tenant_conn:
|
||||||
|
try: tenant_conn.close()
|
||||||
|
except Exception: pass
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
# Auto-reply with AI chatbot
|
|
||||||
if msg.get('text'):
|
|
||||||
try:
|
try:
|
||||||
|
if media_kind == 'image' and msg.get('media_base64'):
|
||||||
|
from services.ai_chat import chat_with_image
|
||||||
|
# Prompt: use the caption if provided, else default to
|
||||||
|
# "identify this part" which chat_with_image handles gracefully.
|
||||||
|
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
||||||
|
ai_resp = chat_with_image(
|
||||||
|
user_message=prompt,
|
||||||
|
image_base64=msg['media_base64'],
|
||||||
|
inventory_context=inventory_context,
|
||||||
|
)
|
||||||
|
reply = ai_resp.get('message', '') or ''
|
||||||
|
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
||||||
|
|
||||||
|
elif media_kind == 'audio' and msg.get('media_base64'):
|
||||||
|
# Voice note handling — transcribe first, then chat().
|
||||||
|
# See services.whisper_local for the transcriber.
|
||||||
|
try:
|
||||||
|
from services.whisper_local import transcribe_audio_base64
|
||||||
|
transcript = transcribe_audio_base64(
|
||||||
|
msg['media_base64'],
|
||||||
|
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
transcript = None
|
||||||
|
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
||||||
|
except Exception as e:
|
||||||
|
transcript = None
|
||||||
|
print(f"[WA-AI] Whisper transcription failed: {e}")
|
||||||
|
|
||||||
|
if transcript:
|
||||||
|
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||||
from services.ai_chat import chat
|
from services.ai_chat import chat
|
||||||
ai_resp = chat(msg['text'])
|
ai_resp = chat(transcript, inventory_context=inventory_context)
|
||||||
reply = ai_resp.get('message', '')
|
reply = ai_resp.get('message', '') or ''
|
||||||
|
# Prefix the reply so the sender knows we understood the voice note
|
||||||
if reply:
|
if reply:
|
||||||
whatsapp_service.send_message(msg['phone'], reply)
|
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||||||
|
else:
|
||||||
|
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||||||
|
'Puedes escribirme el mensaje?')
|
||||||
|
|
||||||
|
elif msg.get('text'):
|
||||||
|
# Plain text message — standard chatbot flow
|
||||||
|
from services.ai_chat import chat
|
||||||
|
ai_resp = chat(msg['text'], inventory_context=inventory_context)
|
||||||
|
reply = ai_resp.get('message', '') or ''
|
||||||
|
|
||||||
|
# Enrich: if the AI returned a search_query, look up real parts
|
||||||
|
# from the catalog and append them to the WhatsApp reply.
|
||||||
|
search_q = ai_resp.get('search_query')
|
||||||
|
vehicle = ai_resp.get('vehicle')
|
||||||
|
if search_q and reply:
|
||||||
|
try:
|
||||||
|
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
|
||||||
|
if enrichment:
|
||||||
|
reply = reply + '\n\n' + enrichment
|
||||||
|
# Track the found part so "cotizar" can add it
|
||||||
|
if found_part:
|
||||||
|
from services.wa_quotation import set_last_shown_part
|
||||||
|
set_last_shown_part(clean_phone, found_part)
|
||||||
|
except Exception as enrich_err:
|
||||||
|
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||||||
|
|
||||||
|
# Send reply if we produced one
|
||||||
|
if reply:
|
||||||
|
result = whatsapp_service.send_message(reply_to, reply)
|
||||||
|
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||||||
|
|
||||||
|
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||||||
|
if tenant_conn:
|
||||||
|
try:
|
||||||
|
cur2 = tenant_conn.cursor()
|
||||||
|
cur2.execute("""
|
||||||
|
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||||
|
VALUES (%s, 'outgoing', %s)
|
||||||
|
""", (msg['phone'], reply))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur2.close()
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||||||
|
|
||||||
|
# 4. Clean up the connection
|
||||||
|
if tenant_conn is not None:
|
||||||
|
try:
|
||||||
|
tenant_conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -119,14 +428,37 @@ def conversations():
|
|||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
|
||||||
|
# so all variants of the same number are grouped together.
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT phone, MAX(message_text) as last_message, MAX(created_at) as last_at, COUNT(*) as msg_count
|
WITH cleaned AS (
|
||||||
|
SELECT
|
||||||
|
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
|
||||||
|
message_text,
|
||||||
|
direction,
|
||||||
|
created_at,
|
||||||
|
push_name
|
||||||
FROM whatsapp_messages
|
FROM whatsapp_messages
|
||||||
GROUP BY phone
|
)
|
||||||
|
SELECT clean_phone,
|
||||||
|
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
|
||||||
|
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
|
||||||
|
MAX(created_at) AS last_at,
|
||||||
|
COUNT(*) AS msg_count,
|
||||||
|
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
|
||||||
|
FROM cleaned
|
||||||
|
GROUP BY clean_phone
|
||||||
ORDER BY MAX(created_at) DESC
|
ORDER BY MAX(created_at) DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
""")
|
""")
|
||||||
convos = [{'phone': r[0], 'last_message': r[1], 'last_at': str(r[2]), 'count': r[3]} for r in cur.fetchall()]
|
convos = [{
|
||||||
|
'phone': r[0],
|
||||||
|
'last_message': r[1] or '',
|
||||||
|
'last_direction': r[2] or 'incoming',
|
||||||
|
'last_at': str(r[3]),
|
||||||
|
'count': r[4],
|
||||||
|
'contact_name': r[5] or '',
|
||||||
|
} for r in cur.fetchall()]
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'conversations': convos})
|
return jsonify({'conversations': convos})
|
||||||
@@ -134,22 +466,68 @@ def conversations():
|
|||||||
return jsonify({'conversations': [], 'error': str(e)})
|
return jsonify({'conversations': [], 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/conversations/<phone>', methods=['GET'])
|
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def conversation_messages(phone):
|
def conversation_messages(phone):
|
||||||
|
# Strip @lid or @s.whatsapp.net suffix for DB lookup
|
||||||
|
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||||||
try:
|
try:
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Match all variants of this phone number
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, direction, message_text, created_at
|
SELECT id, direction, message_text, created_at
|
||||||
FROM whatsapp_messages
|
FROM whatsapp_messages
|
||||||
WHERE phone = %s
|
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
""", (phone,))
|
""", (clean_phone,))
|
||||||
msgs = [{'id': r[0], 'direction': r[1], 'text': r[2], 'date': str(r[3])} for r in cur.fetchall()]
|
msgs = [{
|
||||||
|
'id': r[0],
|
||||||
|
'direction': r[1],
|
||||||
|
'message_text': r[2] or '',
|
||||||
|
'created_at': str(r[3]),
|
||||||
|
} for r in cur.fetchall()]
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'messages': msgs})
|
return jsonify({'messages': msgs})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'messages': [], 'error': str(e)})
|
return jsonify({'messages': [], 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
|
||||||
|
@require_auth()
|
||||||
|
def delete_conversation(phone):
|
||||||
|
"""Delete all messages for a phone number."""
|
||||||
|
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM whatsapp_messages
|
||||||
|
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||||||
|
""", (clean_phone,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True, 'deleted': deleted})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_bp.route('/conversations', methods=['DELETE'])
|
||||||
|
@require_auth()
|
||||||
|
def delete_all_conversations():
|
||||||
|
"""Delete ALL whatsapp messages."""
|
||||||
|
try:
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM whatsapp_messages")
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'ok': True, 'deleted': deleted})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
19
pos/peers.json
Normal file
19
pos/peers.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"instance_name": "Refaccionaria Demo",
|
||||||
|
"instance_id": "refac-demo-001",
|
||||||
|
"tenant_id": 11,
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"name": "Refaccionaria B",
|
||||||
|
"url": "http://192.168.1.20:5001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Refaccionaria C",
|
||||||
|
"url": "http://192.168.1.30:5001",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"peer_timeout_seconds": 3,
|
||||||
|
"notes": "Edit the 'peers' list with the actual IPs of the other instances on your network. Each instance has its own copy of this file with different peers."
|
||||||
|
}
|
||||||
@@ -9,8 +9,20 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|||||||
|
|
||||||
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
|
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
|
||||||
# El modelo DEBE terminar en ":free" para garantizar costo $0.
|
# El modelo DEBE terminar en ":free" para garantizar costo $0.
|
||||||
# Alternativas gratuitas: "meta-llama/llama-4-scout:free", "google/gemma-3-27b-it:free"
|
MODEL = "qwen/qwen3.6-plus:free"
|
||||||
MODEL = "qwen/qwen3.6-plus-preview:free"
|
|
||||||
|
# Fallback chain: si el modelo principal tiene rate limit (429) o 404
|
||||||
|
# (deprecated), intenta los siguientes. Todos :free. Mezclamos proveedores
|
||||||
|
# distintos porque los rate limits aplican por-proveedor.
|
||||||
|
# Lista actualizada 2026-04-09 después de que qwen3.6-plus fue deprecated.
|
||||||
|
FALLBACK_MODELS = [
|
||||||
|
"openai/gpt-oss-120b:free", # OpenInference — gran cobertura
|
||||||
|
"google/gemma-4-31b-it:free", # Google — nuevo, 262K ctx
|
||||||
|
"qwen/qwen3-next-80b-a3b-instruct:free", # Alibaba — 262K ctx
|
||||||
|
"z-ai/glm-4.5-air:free", # Z.AI
|
||||||
|
"google/gemma-3-27b-it:free", # Google — backup vision
|
||||||
|
"meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback
|
||||||
|
]
|
||||||
|
|
||||||
def _validate_model(model_id):
|
def _validate_model(model_id):
|
||||||
"""Ensure only free models are used. Raises if model is not free."""
|
"""Ensure only free models are used. Raises if model is not free."""
|
||||||
@@ -318,15 +330,155 @@ def classify_part(part_number):
|
|||||||
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# RESPONSE CACHE — reduces OpenRouter calls for repeated questions
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Keyed by a normalized form of the user message. TTL 1 hour. Bypasses
|
||||||
|
# caching for messages containing VINs or specific part numbers (where the
|
||||||
|
# answer depends on the exact string).
|
||||||
|
|
||||||
|
import hashlib as _hashlib
|
||||||
|
import re as _re
|
||||||
|
import time as _time_chat
|
||||||
|
|
||||||
|
_RESPONSE_CACHE = {} # key → (expires_at, response_dict)
|
||||||
|
_CACHE_TTL_SECONDS = 3600 # 1 hour
|
||||||
|
_CACHE_MAX_SIZE = 1000
|
||||||
|
_CACHE_HITS = 0
|
||||||
|
_CACHE_MISSES = 0
|
||||||
|
|
||||||
|
# Stopwords that add noise but no meaning — stripped from cache keys.
|
||||||
|
_CACHE_STOPWORDS = {
|
||||||
|
'necesito', 'necesitas', 'me', 'das', 'dame', 'tienes', 'tiene', 'hay',
|
||||||
|
'quiero', 'quisiera', 'puedes', 'puede', 'favor', 'por', 'porfavor',
|
||||||
|
'hola', 'buenos', 'dias', 'tardes', 'noches', 'holaa',
|
||||||
|
'i', 'need', 'want', 'do', 'you', 'have', 'please',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Patterns that disable caching — if the message contains any of these, we
|
||||||
|
# never cache the response because the answer is specific to that exact input.
|
||||||
|
# Rules designed to minimize false positives against normal Spanish queries
|
||||||
|
# like "necesito balatas para corolla 2018".
|
||||||
|
_CACHE_BYPASS_PATTERNS = [
|
||||||
|
# 17-char VIN (strict, no spaces, alphanumeric except I/O/Q)
|
||||||
|
_re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b'),
|
||||||
|
# Long numeric (12+ digits — too long to be a year/model code)
|
||||||
|
_re.compile(r'\b\d{12,}\b'),
|
||||||
|
# Mexican license plate: 3 letters + 3-4 digits
|
||||||
|
_re.compile(r'\b[A-Z]{3}[-\s]?\d{3,4}\b'),
|
||||||
|
# OEM with REQUIRED dash/slash separator(s), letters+digits on both sides,
|
||||||
|
# and a total length that makes it unlikely to be a brand+year collision.
|
||||||
|
# Example matches: "4G0-857-951-A", "0 986 4B7 013" (after normalizing).
|
||||||
|
_re.compile(r'\b[A-Z0-9]{2,}[-/][A-Z0-9]{2,}([-/][A-Z0-9]+)+\b'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_bypass_cache(message: str) -> bool:
|
||||||
|
"""True if the message has VIN / part number / plate — don't cache."""
|
||||||
|
if not message:
|
||||||
|
return True
|
||||||
|
upper = message.upper()
|
||||||
|
for pat in _CACHE_BYPASS_PATTERNS:
|
||||||
|
if pat.search(upper):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_cache(message: str) -> str:
|
||||||
|
"""Lowercase, strip punctuation, collapse whitespace, drop stopwords."""
|
||||||
|
if not message:
|
||||||
|
return ''
|
||||||
|
s = message.lower().strip()
|
||||||
|
s = _re.sub(r'[¿?¡!.,;:()\[\]{}\'"]+', ' ', s)
|
||||||
|
s = _re.sub(r'\s+', ' ', s).strip()
|
||||||
|
tokens = [t for t in s.split() if t and t not in _CACHE_STOPWORDS]
|
||||||
|
return ' '.join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(user_message: str, inventory_context: str | None) -> str | None:
|
||||||
|
"""Build a stable cache key for (message, inventory_context).
|
||||||
|
|
||||||
|
Returns None if the message should bypass the cache.
|
||||||
|
"""
|
||||||
|
if _should_bypass_cache(user_message):
|
||||||
|
return None
|
||||||
|
normalized = _normalize_for_cache(user_message)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
# Hash the inventory context so same-tenant-same-question cache hits,
|
||||||
|
# different-tenant-same-question does NOT (inventory context differs).
|
||||||
|
ctx_hash = _hashlib.md5((inventory_context or '').encode()).hexdigest()[:12]
|
||||||
|
return f"{normalized}::{ctx_hash}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str):
|
||||||
|
global _CACHE_HITS, _CACHE_MISSES
|
||||||
|
if not key:
|
||||||
|
_CACHE_MISSES += 1
|
||||||
|
return None
|
||||||
|
entry = _RESPONSE_CACHE.get(key)
|
||||||
|
if not entry:
|
||||||
|
_CACHE_MISSES += 1
|
||||||
|
return None
|
||||||
|
expires_at, data = entry
|
||||||
|
if _time_chat.time() > expires_at:
|
||||||
|
_RESPONSE_CACHE.pop(key, None)
|
||||||
|
_CACHE_MISSES += 1
|
||||||
|
return None
|
||||||
|
_CACHE_HITS += 1
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: dict):
|
||||||
|
if not key or not data:
|
||||||
|
return
|
||||||
|
_RESPONSE_CACHE[key] = (_time_chat.time() + _CACHE_TTL_SECONDS, data)
|
||||||
|
# Bounded cache — evict oldest entries if we grow past the limit
|
||||||
|
if len(_RESPONSE_CACHE) > _CACHE_MAX_SIZE:
|
||||||
|
oldest_keys = sorted(
|
||||||
|
_RESPONSE_CACHE.items(), key=lambda kv: kv[1][0]
|
||||||
|
)[:200]
|
||||||
|
for k, _v in oldest_keys:
|
||||||
|
_RESPONSE_CACHE.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_cache_stats() -> dict:
|
||||||
|
"""Diagnostic helper: hit rate and cache size."""
|
||||||
|
total = _CACHE_HITS + _CACHE_MISSES
|
||||||
|
hit_rate = (_CACHE_HITS * 100 / total) if total else 0
|
||||||
|
return {
|
||||||
|
'entries': len(_RESPONSE_CACHE),
|
||||||
|
'hits': _CACHE_HITS,
|
||||||
|
'misses': _CACHE_MISSES,
|
||||||
|
'hit_rate_pct': round(hit_rate, 1),
|
||||||
|
'ttl_seconds': _CACHE_TTL_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chat_cache_clear():
|
||||||
|
"""Manual cache invalidation — e.g. after inventory bulk changes."""
|
||||||
|
_RESPONSE_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def chat(user_message, conversation_history=None, inventory_context=None):
|
def chat(user_message, conversation_history=None, inventory_context=None):
|
||||||
"""Send a message to the AI and get a response with search suggestions.
|
"""Send a message to the AI and get a response with search suggestions.
|
||||||
|
|
||||||
|
Caches responses for repeated identical questions (subject to bypass
|
||||||
|
rules — messages with VINs / part numbers / plates are never cached).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_message: The user's chat message.
|
user_message: The user's chat message.
|
||||||
conversation_history: Previous messages in the conversation.
|
conversation_history: Previous messages in the conversation.
|
||||||
inventory_context: Optional inventory summary string to inject into the system prompt.
|
inventory_context: Optional inventory summary string to inject into the system prompt.
|
||||||
"""
|
"""
|
||||||
_validate_model(MODEL) # Block paid models
|
# Cache lookup — only when there's no conversation history (stateless)
|
||||||
|
cache_key = None
|
||||||
|
if not conversation_history:
|
||||||
|
cache_key = _cache_key(user_message, inventory_context)
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
print(f"[AI] Cache HIT for '{user_message[:40]}...'")
|
||||||
|
return cached
|
||||||
|
|
||||||
system_content = SYSTEM_PROMPT
|
system_content = SYSTEM_PROMPT
|
||||||
if inventory_context:
|
if inventory_context:
|
||||||
@@ -337,10 +489,11 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
messages.extend(conversation_history)
|
messages.extend(conversation_history)
|
||||||
messages.append({"role": "user", "content": user_message})
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
import time
|
last_error = None
|
||||||
max_retries = 3
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
# Try each model in the fallback chain on 429 (rate limit)
|
||||||
|
for model_id in FALLBACK_MODELS:
|
||||||
|
_validate_model(model_id) # Block paid models
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
OPENROUTER_URL,
|
OPENROUTER_URL,
|
||||||
@@ -349,23 +502,32 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": MODEL,
|
"model": model_id,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": 500,
|
"max_tokens": 800,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
},
|
},
|
||||||
timeout=20,
|
timeout=25,
|
||||||
)
|
)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
# Rate limited — wait and retry
|
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||||
wait = (attempt + 1) * 5 # 5s, 10s, 15s
|
last_error = "rate_limit"
|
||||||
if attempt < max_retries - 1:
|
continue
|
||||||
time.sleep(wait)
|
if resp.status_code >= 400:
|
||||||
|
print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}")
|
||||||
|
last_error = f"http_{resp.status_code}"
|
||||||
continue
|
continue
|
||||||
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
content = data["choices"][0]["message"]["content"]
|
choice = data.get("choices", [{}])[0]
|
||||||
|
content = choice.get("message", {}).get("content", "").strip()
|
||||||
|
finish = choice.get("finish_reason", "")
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
print(f"[AI] Empty response from {model_id} (finish={finish})")
|
||||||
|
last_error = "empty_response"
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
|
||||||
|
|
||||||
# Try to parse JSON response
|
# Try to parse JSON response
|
||||||
try:
|
try:
|
||||||
@@ -376,14 +538,27 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
|||||||
parsed = json.loads(json_str)
|
parsed = json.loads(json_str)
|
||||||
else:
|
else:
|
||||||
parsed = json.loads(stripped)
|
parsed = json.loads(stripped)
|
||||||
|
# Successful JSON response — cache it
|
||||||
|
if cache_key:
|
||||||
|
_cache_set(cache_key, parsed)
|
||||||
return parsed
|
return parsed
|
||||||
except (json.JSONDecodeError, IndexError):
|
except (json.JSONDecodeError, IndexError):
|
||||||
return {"message": content, "search_query": None, "vehicle": None}
|
fallback = {"message": content, "search_query": None, "vehicle": None}
|
||||||
|
# Cache the fallback too — the model gave us a real answer,
|
||||||
|
# it just wasn't JSON. Next hit saves the API call.
|
||||||
|
if cache_key:
|
||||||
|
_cache_set(cache_key, fallback)
|
||||||
|
return fallback
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if attempt < max_retries - 1:
|
print(f"[AI] Error with {model_id}: {e}")
|
||||||
|
last_error = str(e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# All models exhausted — DON'T cache errors, we want retries next time
|
||||||
|
if last_error == "rate_limit":
|
||||||
|
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||||
return {
|
return {
|
||||||
"message": f"Error de conexion: {str(e)}",
|
"message": f"Error de conexion: {last_error}",
|
||||||
"search_query": None,
|
"search_query": None,
|
||||||
"vehicle": None,
|
"vehicle": None,
|
||||||
}
|
}
|
||||||
|
|||||||
129
pos/services/catalog_modes.py
Normal file
129
pos/services/catalog_modes.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Catalog modes — OEM vs Local bodega filtering for brand lists.
|
||||||
|
|
||||||
|
Two catalog modes coexist:
|
||||||
|
|
||||||
|
- 'oem' : Full TecDoc catalog (36+ vehicle brands from Apify import).
|
||||||
|
Use this for any customer-facing "find your exact OEM part" flow.
|
||||||
|
|
||||||
|
- 'local' : Curated list of vehicle brands that local bodegas in Mexico
|
||||||
|
actually service. Used while the TecDoc/Apify import is paused
|
||||||
|
or to simplify navigation for customers who only care about
|
||||||
|
what's available locally.
|
||||||
|
|
||||||
|
Both modes use the SAME navigation hierarchy (brand > model > year > engine >
|
||||||
|
category > parts). Only the initial brand list is filtered.
|
||||||
|
|
||||||
|
Edit LOCAL_BODEGA_BRANDS below to add/remove brands as the bodega network grows.
|
||||||
|
Brand names must match the `brands.name_brand` column in nexus_autoparts
|
||||||
|
(case-sensitive, uppercase).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ─── OEM mode — full North America coverage (imported from TecDoc) ──────────
|
||||||
|
OEM_BRANDS_NA = (
|
||||||
|
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
|
||||||
|
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
|
||||||
|
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
|
||||||
|
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
|
||||||
|
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
|
||||||
|
'VOLVO', 'VW',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Local mode — brands actually stocked by Mexican bodegas ────────────────
|
||||||
|
# Popular Mexican market passenger cars + light trucks. Edit as needed.
|
||||||
|
LOCAL_BODEGA_BRANDS = (
|
||||||
|
'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara
|
||||||
|
'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle
|
||||||
|
'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail
|
||||||
|
'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo
|
||||||
|
'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma
|
||||||
|
'HONDA', # Civic, City, CR-V, Fit, HR-V
|
||||||
|
'DODGE', # Attitude, Neon, Journey
|
||||||
|
'CHRYSLER',
|
||||||
|
'RAM', # Pickups
|
||||||
|
'HYUNDAI', # Accent, Grand i10, Tucson, Elantra
|
||||||
|
'KIA', # Rio, Forte, Sportage, Sorento
|
||||||
|
'MAZDA', # 2, 3, CX-5, CX-30
|
||||||
|
'MITSUBISHI', # Lancer, L200, Outlander
|
||||||
|
'RENAULT', # Logan, Sandero, Duster, Stepway
|
||||||
|
'SEAT', # Ibiza, Leon, Arona
|
||||||
|
'FIAT', # Uno, Palio, Mobi
|
||||||
|
'SUZUKI', # Swift, Vitara, Ignis, Ertiga
|
||||||
|
'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade
|
||||||
|
'GMC', # Sierra, Terrain
|
||||||
|
'BUICK', # Encore, Enclave (GM)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_brands_for_mode(mode):
|
||||||
|
"""Return the tuple of allowed brand names for a given catalog mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: 'oem' or 'local'. Anything else defaults to 'oem'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of uppercase brand name strings.
|
||||||
|
"""
|
||||||
|
if mode == 'local':
|
||||||
|
return LOCAL_BODEGA_BRANDS
|
||||||
|
return OEM_BRANDS_NA
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mode(raw):
|
||||||
|
"""Normalize a raw mode string from a query param or header."""
|
||||||
|
if not raw:
|
||||||
|
return 'oem'
|
||||||
|
cleaned = str(raw).strip().lower()
|
||||||
|
return 'local' if cleaned == 'local' else 'oem'
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Local mode — priority aftermarket manufacturer brands ─────────────────
|
||||||
|
# Ordered list. Brands earlier in the list are shown first and get the top
|
||||||
|
# "priority" badge in the UI. Match `manufacturers.name_manufacture` (uppercase).
|
||||||
|
#
|
||||||
|
# Tier 1 (most trusted / most stocked in Mexican bodegas) — shown first.
|
||||||
|
# Tier 2 (also popular but not always on every shelf) — shown second.
|
||||||
|
# Anything not in either list is "other" and shown last.
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER1 = (
|
||||||
|
'BOSCH', # Universal — ignition, sensors, filters, wipers
|
||||||
|
'GATES', # Bandas / timing belts
|
||||||
|
'MONROE', # Amortiguadores
|
||||||
|
'DENSO', # Ignition, cooling, AC
|
||||||
|
'MANN-FILTER', # Filtros
|
||||||
|
'MAHLE', # Filtros, pistones, termostatos
|
||||||
|
'NGK', # Bujias
|
||||||
|
'BREMBO', # Frenos premium
|
||||||
|
'KYB', # Amortiguadores
|
||||||
|
'SKF', # Rodamientos
|
||||||
|
)
|
||||||
|
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER2 = (
|
||||||
|
'DELPHI',
|
||||||
|
'VALEO',
|
||||||
|
'HELLA',
|
||||||
|
'DAYCO',
|
||||||
|
'SACHS',
|
||||||
|
'CHAMPION',
|
||||||
|
'WAGNER',
|
||||||
|
'FRAM',
|
||||||
|
'NSK',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combined flat tuple (Tier1 followed by Tier2) — used for SQL IN clauses
|
||||||
|
# and for determining "any priority" status.
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS = LOCAL_PRIORITY_MANUFACTURERS_TIER1 + LOCAL_PRIORITY_MANUFACTURERS_TIER2
|
||||||
|
|
||||||
|
|
||||||
|
def get_priority_tier(manufacturer_name):
|
||||||
|
"""Return 1 for tier 1, 2 for tier 2, 3 for everything else.
|
||||||
|
|
||||||
|
Used both by the sort order and by the UI to render a priority badge.
|
||||||
|
"""
|
||||||
|
if not manufacturer_name:
|
||||||
|
return 3
|
||||||
|
name = manufacturer_name.upper()
|
||||||
|
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER1:
|
||||||
|
return 1
|
||||||
|
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER2:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
@@ -42,19 +42,22 @@ def _clean_model_name(name):
|
|||||||
# VEHICLE HIERARCHY NAVIGATION
|
# VEHICLE HIERARCHY NAVIGATION
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
NORTH_AMERICA_BRANDS = (
|
from services.catalog_modes import get_brands_for_mode
|
||||||
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
|
|
||||||
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
|
# Legacy alias — kept for backwards compatibility with any existing imports.
|
||||||
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
|
# Prefer `catalog_modes.OEM_BRANDS_NA` in new code.
|
||||||
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
|
NORTH_AMERICA_BRANDS = get_brands_for_mode('oem')
|
||||||
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
|
|
||||||
'VOLVO', 'VW',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_brands(master_conn, year_id=None):
|
def get_brands(master_conn, year_id=None, mode='oem'):
|
||||||
"""Get vehicle brands available in Mexico/USA/Canada that have MYE entries.
|
"""Get vehicle brands that have MYE entries, filtered by catalog mode.
|
||||||
If year_id is provided, only brands that have models for that year."""
|
|
||||||
|
Args:
|
||||||
|
master_conn: Connection to the nexus_autoparts master DB.
|
||||||
|
year_id: Optional — only return brands with models for that year.
|
||||||
|
mode: 'oem' (full TecDoc coverage) or 'local' (curated bodega list).
|
||||||
|
"""
|
||||||
|
allowed = list(get_brands_for_mode(mode))
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
if year_id:
|
if year_id:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -64,7 +67,7 @@ def get_brands(master_conn, year_id=None):
|
|||||||
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
||||||
WHERE b.name_brand = ANY(%s) AND mye.year_id = %s
|
WHERE b.name_brand = ANY(%s) AND mye.year_id = %s
|
||||||
ORDER BY b.name_brand
|
ORDER BY b.name_brand
|
||||||
""", (list(NORTH_AMERICA_BRANDS), year_id))
|
""", (allowed, year_id))
|
||||||
else:
|
else:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT DISTINCT b.id_brand, b.name_brand
|
SELECT DISTINCT b.id_brand, b.name_brand
|
||||||
@@ -73,7 +76,7 @@ def get_brands(master_conn, year_id=None):
|
|||||||
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
||||||
WHERE b.name_brand = ANY(%s)
|
WHERE b.name_brand = ANY(%s)
|
||||||
ORDER BY b.name_brand
|
ORDER BY b.name_brand
|
||||||
""", (list(NORTH_AMERICA_BRANDS),))
|
""", (allowed,))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows]
|
return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows]
|
||||||
@@ -189,6 +192,509 @@ def get_categories(master_conn, mye_id):
|
|||||||
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
|
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# NEXPART HIERARCHY (Local mode) — filtered by what TecDoc has for this vehicle
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ─── In-memory cache for vehicle → Nexpart classification ─────────────────
|
||||||
|
# Key: mye_id (int). Value: (expires_at_timestamp, classified_dict).
|
||||||
|
# TTL is short (5 min) because catalog data rarely changes but we don't
|
||||||
|
# want stale data lingering across sessions. Single-process cache —
|
||||||
|
# Gunicorn workers each have their own, which is fine for this workload.
|
||||||
|
import time as _time
|
||||||
|
_CLASSIFY_CACHE = {}
|
||||||
|
_CLASSIFY_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_cache_get(mye_id):
|
||||||
|
entry = _CLASSIFY_CACHE.get(mye_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
expires_at, data = entry
|
||||||
|
if _time.time() > expires_at:
|
||||||
|
_CLASSIFY_CACHE.pop(mye_id, None)
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_cache_set(mye_id, data):
|
||||||
|
_CLASSIFY_CACHE[mye_id] = (_time.time() + _CLASSIFY_TTL_SECONDS, data)
|
||||||
|
# Simple unbounded-growth protection: if cache grows past 500 entries,
|
||||||
|
# evict the oldest half. Real production would use an LRU library.
|
||||||
|
if len(_CLASSIFY_CACHE) > 500:
|
||||||
|
sorted_keys = sorted(_CLASSIFY_CACHE.items(), key=lambda kv: kv[1][0])
|
||||||
|
for k, _v in sorted_keys[:250]:
|
||||||
|
_CLASSIFY_CACHE.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_cache_clear():
|
||||||
|
"""Manual cache invalidation — call after catalog import."""
|
||||||
|
_CLASSIFY_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_cache_stats():
|
||||||
|
"""Diagnostic helper for the cache state."""
|
||||||
|
now = _time.time()
|
||||||
|
alive = sum(1 for expires, _ in _CLASSIFY_CACHE.values() if expires > now)
|
||||||
|
return {
|
||||||
|
'total_entries': len(_CLASSIFY_CACHE),
|
||||||
|
'alive': alive,
|
||||||
|
'expired': len(_CLASSIFY_CACHE) - alive,
|
||||||
|
'ttl_seconds': _CLASSIFY_TTL_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_vehicle_parts(master_conn, mye_id):
|
||||||
|
"""Classify all TecDoc parts for a vehicle into Nexpart triples.
|
||||||
|
|
||||||
|
Runs the matcher once per distinct part name, builds a nested dict:
|
||||||
|
{
|
||||||
|
"Brake System...": {
|
||||||
|
"Front Friction, Drums & Rotors": {
|
||||||
|
"Front Disc Brake Rotor": [oem_part_id, ...],
|
||||||
|
...
|
||||||
|
},
|
||||||
|
...
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
Drops parts whose name has no Nexpart equivalent (UNMAPPED_STRATEGY=drop).
|
||||||
|
Used by all 3 Nexpart-filtered functions below — cached by mye_id so
|
||||||
|
one navigation sequence (categories → subgroups → part types → parts)
|
||||||
|
does the classification work exactly once.
|
||||||
|
"""
|
||||||
|
# Cache hit — skip the query and matcher entirely
|
||||||
|
cached = _classify_cache_get(mye_id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
from services.nexpart_taxonomy import tecdoc_to_nexpart
|
||||||
|
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id_part, p.name_part
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
""", (mye_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
classified = {}
|
||||||
|
for part_id, name_part in rows:
|
||||||
|
triple = tecdoc_to_nexpart(name_part)
|
||||||
|
if not triple:
|
||||||
|
continue # drop unmapped (Decision 2)
|
||||||
|
group, subgroup, part_type = triple
|
||||||
|
classified.setdefault(group, {}) \
|
||||||
|
.setdefault(subgroup, {}) \
|
||||||
|
.setdefault(part_type, []) \
|
||||||
|
.append(part_id)
|
||||||
|
|
||||||
|
_classify_cache_set(mye_id, classified)
|
||||||
|
return classified
|
||||||
|
|
||||||
|
|
||||||
|
def get_nexpart_groups_for_vehicle(master_conn, mye_id):
|
||||||
|
"""Local mode: return Nexpart top-level groups that have parts for this vehicle.
|
||||||
|
|
||||||
|
Output shape mirrors get_categories() but uses `slug` (string) instead of
|
||||||
|
integer category_id. Empty groups are dropped so the user only sees
|
||||||
|
categories with at least one matched part.
|
||||||
|
"""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
|
||||||
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
# Iterate in canonical Nexpart order so the UI is stable
|
||||||
|
for group in NEXPART_TAXONOMY.keys():
|
||||||
|
if group not in classified:
|
||||||
|
continue
|
||||||
|
# Count distinct part_types matched in this group across all subgroups
|
||||||
|
part_count = sum(
|
||||||
|
len(parts)
|
||||||
|
for subgroup_dict in classified[group].values()
|
||||||
|
for parts in subgroup_dict.values()
|
||||||
|
)
|
||||||
|
result.append({
|
||||||
|
'slug': group,
|
||||||
|
'name': translate_taxonomy_node(group),
|
||||||
|
'name_en': group,
|
||||||
|
'part_count': part_count,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug):
|
||||||
|
"""Local mode: return Nexpart subgroups within a group that have vehicle parts."""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
|
||||||
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
|
group_data = classified.get(group_slug, {})
|
||||||
|
if not group_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Iterate in the canonical order from NEXPART_TAXONOMY for stability
|
||||||
|
canonical_order = list(NEXPART_TAXONOMY.get(group_slug, {}).keys())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for subgroup in canonical_order:
|
||||||
|
if subgroup not in group_data:
|
||||||
|
continue
|
||||||
|
part_count = sum(len(p) for p in group_data[subgroup].values())
|
||||||
|
result.append({
|
||||||
|
'slug': subgroup,
|
||||||
|
'name': translate_taxonomy_node(subgroup),
|
||||||
|
'name_en': subgroup,
|
||||||
|
'part_count': part_count,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SHOP SUPPLIES — vehicle-independent parts (Network Shop Supplies tab)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# These live under 2 Nexpart groups that don't require a vehicle selection:
|
||||||
|
# - Chemicals, Waxes & Lubricants (oils, fluids, additives)
|
||||||
|
# - Tires, Wheels, Tools & Accessory Parts (TPMS, lug nuts, universal clips)
|
||||||
|
#
|
||||||
|
# The navigation skips the Year→Make→Model→Engine chain and goes directly
|
||||||
|
# to group selection. The query scans `parts` globally without joining
|
||||||
|
# `vehicle_parts` (which is HUGE), so it's fast.
|
||||||
|
|
||||||
|
# The 2 Nexpart groups that are safely vehicle-independent.
|
||||||
|
_SHOP_SUPPLIES_GROUPS = (
|
||||||
|
"Chemicals, Waxes & Lubricants",
|
||||||
|
"Tires, Wheels, Tools & Accessory Parts",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maps each Nexpart Part Type in the universal groups to a list of SQL ILIKE
|
||||||
|
# patterns that match the actual TecDoc name_part values. This inverts the
|
||||||
|
# forward matcher (which goes TecDoc → Nexpart) — here we're asking "which
|
||||||
|
# TecDoc part names should be classified into this Nexpart Part Type?"
|
||||||
|
#
|
||||||
|
# Built by inspecting real name_part values in the parts table. Grow this
|
||||||
|
# map when you see shop supplies that are missing from the results.
|
||||||
|
SHOP_SUPPLIES_PATTERNS = {
|
||||||
|
# Chemicals, Waxes & Lubricants
|
||||||
|
"Engine Oil": ["Engine Oil"],
|
||||||
|
"Automatic Transmission Fluid": ["Automatic Transmission Fluid", "Transmission Oil"],
|
||||||
|
|
||||||
|
# Tires & Wheels (TPMS + lug hardware)
|
||||||
|
"TPMS Sensor": ["TPMS%", "Tire Pressure Sensor%"],
|
||||||
|
"TPMS Programmable Sensor": ["%TPMS%Programmable%"],
|
||||||
|
"TPMS Sensor Service Kit": ["%TPMS%Service Kit%", "%TPMS%Repair Kit%"],
|
||||||
|
"TPMS Sensor Valve Assembly": ["%TPMS%Valve%"],
|
||||||
|
"TPMS Valve Kit": ["%TPMS%Valve Kit%", "Valve, tyre pressure control system"],
|
||||||
|
"TPMS Programmable Sensor Valve Kit": ["%TPMS%Programmable%Valve%"],
|
||||||
|
"Wheel Lug Nut": ["Wheel Nut"],
|
||||||
|
"Wheel Lug Stud": ["Wheel Bolt", "Wheel Stud"],
|
||||||
|
|
||||||
|
# Bumper & License Plate (universal clips)
|
||||||
|
"Bumper Clip": ["%Clip%bumper%", "%bumper%Clip%"],
|
||||||
|
"Bumper Cover Retainer": ["%bumper cover%", "Retainer%bumper%"],
|
||||||
|
"Bumper Cover Trim Panel Retainer": ["%Retainer%trim%", "%bumper trim%"],
|
||||||
|
"License Plate Bracket Rivet": ["%license plate%rivet%", "%plate%bracket%"],
|
||||||
|
|
||||||
|
# Hood, Fender & Body Parts (universal clips)
|
||||||
|
"Cowl Panel Retainer": ["%cowl%retainer%", "Clip, cowl%"],
|
||||||
|
"Door Sill Plate Clip": ["%door sill%clip%", "Clip, sill%"],
|
||||||
|
"Exterior Molding Clip": ["%Clip%trim%", "%trim%strip%Clip%"],
|
||||||
|
"Interior Panel Clip": ["Clip, trim%"],
|
||||||
|
"Rocker Panel Molding Retainer": ["%rocker%retainer%"],
|
||||||
|
"Spoiler Clip": ["Clip, spoiler%", "%spoiler%Clip%"],
|
||||||
|
"Undercar Shield Clip": ["%undercar shield%", "%underbody%Clip%"],
|
||||||
|
|
||||||
|
# Tools, Jacks, Hardware & Manuals — mostly not present in TecDoc
|
||||||
|
"Cooling System Flush Gun Kit": ["%cooling system flush%"],
|
||||||
|
"Molding Clip": ["Clip, moulding%", "Clip, molding%"],
|
||||||
|
"Multi-Purpose Retainer": ["Retainer%", "Clip, mounting%"],
|
||||||
|
"Interior Panel Retainer": ["Retainer%panel%", "Clip, interior panel%"],
|
||||||
|
|
||||||
|
# Interior & Steering Wheel — mostly connectors (sparse in TecDoc)
|
||||||
|
"Accelerator Pedal Sensor Connector": ["%accelerator pedal%connector%"],
|
||||||
|
"Clutch Pedal Position Switch Connector": ["%clutch pedal%connector%"],
|
||||||
|
"Console Trim Panel Clip": ["%console%clip%"],
|
||||||
|
|
||||||
|
# Electronics Audio/Visual & Mirrors
|
||||||
|
"Antenna Mast": ["%antenna mast%", "%antenna%"],
|
||||||
|
"Interior Rear View Mirror Connector": ["%rear view mirror%connector%"],
|
||||||
|
"Interior Rear View Mirror Mounting Base": ["%mirror%mounting base%"],
|
||||||
|
"Keyless Entry Transmitter Cover": ["%keyless%cover%"],
|
||||||
|
"Lane Departure System Camera": ["%lane departure%"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _shop_supplies_count_by_part_type(master_conn, part_type_names):
|
||||||
|
"""Given a list of Nexpart Part Type names (the Shop Supplies-eligible ones),
|
||||||
|
return {part_type: total_count} using the SHOP_SUPPLIES_PATTERNS map.
|
||||||
|
|
||||||
|
Uses one query per Part Type because the patterns are OR'd via ILIKE and
|
||||||
|
we need a per-PT count. Still fast because patterns are indexed via
|
||||||
|
trigram if enabled, or just full-scan on 1.5M rows (~500ms total).
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
for pt in part_type_names:
|
||||||
|
patterns = SHOP_SUPPLIES_PATTERNS.get(pt)
|
||||||
|
if not patterns:
|
||||||
|
continue
|
||||||
|
# Build a WHERE clause with multiple ILIKE ORs
|
||||||
|
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT COUNT(*) FROM parts WHERE {like_parts}",
|
||||||
|
patterns,
|
||||||
|
)
|
||||||
|
count = cur.fetchone()[0] or 0
|
||||||
|
if count > 0:
|
||||||
|
result[pt] = count
|
||||||
|
cur.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _shop_supplies_oem_ids(master_conn, part_type_name, limit=5000):
|
||||||
|
"""Return the OEM id_part values that match a Shop Supplies Part Type."""
|
||||||
|
patterns = SHOP_SUPPLIES_PATTERNS.get(part_type_name)
|
||||||
|
if not patterns:
|
||||||
|
return []
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id_part FROM parts WHERE {like_parts} LIMIT %s",
|
||||||
|
patterns + [limit],
|
||||||
|
)
|
||||||
|
ids = [row[0] for row in cur.fetchall()]
|
||||||
|
cur.close()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def get_shop_supplies_groups():
|
||||||
|
"""Return the 2 Nexpart groups that don't require a vehicle.
|
||||||
|
|
||||||
|
Unlike the vehicle-scoped get_nexpart_groups_for_vehicle(), this returns
|
||||||
|
ALL subgroups of these groups regardless of whether there are matching
|
||||||
|
parts in the DB — that check happens at the subgroup level to avoid
|
||||||
|
scanning `parts` multiple times.
|
||||||
|
"""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for group in _SHOP_SUPPLIES_GROUPS:
|
||||||
|
if group not in NEXPART_TAXONOMY:
|
||||||
|
continue
|
||||||
|
subgroup_count = len(NEXPART_TAXONOMY[group])
|
||||||
|
part_type_count = sum(len(pts) for pts in NEXPART_TAXONOMY[group].values())
|
||||||
|
result.append({
|
||||||
|
'slug': group,
|
||||||
|
'name': translate_taxonomy_node(group),
|
||||||
|
'name_en': group,
|
||||||
|
'part_count': part_type_count, # count of distinct Part Types, not parts
|
||||||
|
'subgroup_count': subgroup_count,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_shop_supplies_subgroups(master_conn, group_slug):
|
||||||
|
"""Return subgroups in a Shop Supplies group that have actual TecDoc parts."""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
if group_slug not in _SHOP_SUPPLIES_GROUPS:
|
||||||
|
return []
|
||||||
|
if group_slug not in NEXPART_TAXONOMY:
|
||||||
|
return []
|
||||||
|
|
||||||
|
subgroups = NEXPART_TAXONOMY[group_slug]
|
||||||
|
# Count each Part Type via the SHOP_SUPPLIES_PATTERNS map (ILIKE-based
|
||||||
|
# inverse search that handles naming gaps between Nexpart and TecDoc).
|
||||||
|
all_part_types = [pt for pts in subgroups.values() for pt in pts]
|
||||||
|
counts_by_pt = _shop_supplies_count_by_part_type(master_conn, all_part_types)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for sg_name, pt_list in subgroups.items():
|
||||||
|
total = sum(counts_by_pt.get(pt, 0) for pt in pt_list)
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
'slug': sg_name,
|
||||||
|
'name': translate_taxonomy_node(sg_name),
|
||||||
|
'name_en': sg_name,
|
||||||
|
'part_count': total,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_shop_supplies_part_types(master_conn, group_slug, subgroup_slug):
|
||||||
|
"""Return Part Types within a Shop Supplies subgroup that have TecDoc parts."""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
if group_slug not in _SHOP_SUPPLIES_GROUPS:
|
||||||
|
return []
|
||||||
|
subgroups = NEXPART_TAXONOMY.get(group_slug, {})
|
||||||
|
part_types = subgroups.get(subgroup_slug, [])
|
||||||
|
if not part_types:
|
||||||
|
return []
|
||||||
|
|
||||||
|
counts_by_pt = _shop_supplies_count_by_part_type(master_conn, part_types)
|
||||||
|
|
||||||
|
# Also fetch a sample image for each matched Part Type
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
result = []
|
||||||
|
for pt in part_types:
|
||||||
|
cnt = counts_by_pt.get(pt, 0)
|
||||||
|
if cnt == 0:
|
||||||
|
continue
|
||||||
|
patterns = SHOP_SUPPLIES_PATTERNS.get(pt, [])
|
||||||
|
if patterns:
|
||||||
|
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT image_url FROM parts WHERE ({like_parts}) AND image_url IS NOT NULL LIMIT 1",
|
||||||
|
patterns,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
sample_image = row[0] if row else None
|
||||||
|
else:
|
||||||
|
sample_image = None
|
||||||
|
result.append({
|
||||||
|
'slug': pt,
|
||||||
|
'name': translate_taxonomy_node(pt),
|
||||||
|
'name_en': pt,
|
||||||
|
'variant_count': cnt,
|
||||||
|
'sample_image': sample_image,
|
||||||
|
})
|
||||||
|
cur.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_slug,
|
||||||
|
tenant_conn, branch_id, page=1, per_page=30):
|
||||||
|
"""Return paginated parts (with aftermarket enrichment) for a Shop Supplies triple.
|
||||||
|
|
||||||
|
Same output shape as get_parts_for_nexpart_triple() — reuses get_parts_local
|
||||||
|
with an explicit OEM part ID list.
|
||||||
|
"""
|
||||||
|
from services.nexpart_taxonomy import NEXPART_TAXONOMY
|
||||||
|
|
||||||
|
if group_slug not in _SHOP_SUPPLIES_GROUPS:
|
||||||
|
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
|
||||||
|
|
||||||
|
# Validate that the requested part type exists in the taxonomy
|
||||||
|
valid_pts = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, [])
|
||||||
|
if part_type_slug not in valid_pts:
|
||||||
|
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
|
||||||
|
|
||||||
|
# Fetch OEM IDs via the inverse-pattern map (handles TecDoc/Nexpart name gaps)
|
||||||
|
oem_part_ids = _shop_supplies_oem_ids(master_conn, part_type_slug)
|
||||||
|
if not oem_part_ids:
|
||||||
|
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
|
||||||
|
|
||||||
|
# Reuse the aftermarket-enriched query path
|
||||||
|
return get_parts_local(
|
||||||
|
master_conn, mye_id=None, group_id=None,
|
||||||
|
tenant_conn=tenant_conn, branch_id=branch_id,
|
||||||
|
page=page, per_page=per_page,
|
||||||
|
oem_part_ids=oem_part_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||||
|
part_type_slug, tenant_conn, branch_id,
|
||||||
|
page=1, per_page=30):
|
||||||
|
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Classify the vehicle's parts to find which OEM id_part values
|
||||||
|
map to (group, subgroup, part_type).
|
||||||
|
2. Delegate to get_parts_local() with the resulting OEM part IDs.
|
||||||
|
|
||||||
|
Returns the same shape as get_parts_local().
|
||||||
|
"""
|
||||||
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
|
part_ids = (
|
||||||
|
classified
|
||||||
|
.get(group_slug, {})
|
||||||
|
.get(subgroup_slug, {})
|
||||||
|
.get(part_type_slug, [])
|
||||||
|
)
|
||||||
|
if not part_ids:
|
||||||
|
return {
|
||||||
|
'data': [],
|
||||||
|
'pagination': _pagination(page, per_page, 0),
|
||||||
|
'mode': 'local',
|
||||||
|
}
|
||||||
|
return get_parts_local(
|
||||||
|
master_conn, mye_id=None, group_id=None,
|
||||||
|
tenant_conn=tenant_conn, branch_id=branch_id,
|
||||||
|
page=page, per_page=per_page,
|
||||||
|
oem_part_ids=part_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug):
|
||||||
|
"""Local mode: return Nexpart part types within a subgroup that have vehicle parts.
|
||||||
|
|
||||||
|
Output shape matches get_part_types() so the frontend can render with
|
||||||
|
minimal branching: each item has slug + name + variant_count + sample_image.
|
||||||
|
"""
|
||||||
|
from services.nexpart_taxonomy import (
|
||||||
|
NEXPART_TAXONOMY,
|
||||||
|
translate_taxonomy_node,
|
||||||
|
)
|
||||||
|
|
||||||
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
|
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
|
||||||
|
if not subgroup_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Pull a sample image for each part type — single query, all part_ids at once
|
||||||
|
all_part_ids = [
|
||||||
|
pid
|
||||||
|
for pids in subgroup_data.values()
|
||||||
|
for pid in pids
|
||||||
|
]
|
||||||
|
image_map = {}
|
||||||
|
if all_part_ids:
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_part, image_url
|
||||||
|
FROM parts
|
||||||
|
WHERE id_part = ANY(%s) AND image_url IS NOT NULL
|
||||||
|
""", (all_part_ids,))
|
||||||
|
for pid, url in cur.fetchall():
|
||||||
|
image_map[pid] = url
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
canonical_order = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, [])
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for pt in canonical_order:
|
||||||
|
if pt not in subgroup_data:
|
||||||
|
continue
|
||||||
|
part_ids = subgroup_data[pt]
|
||||||
|
sample_image = next((image_map[pid] for pid in part_ids if pid in image_map), None)
|
||||||
|
result.append({
|
||||||
|
'slug': pt,
|
||||||
|
'name': translate_taxonomy_node(pt),
|
||||||
|
'name_en': pt,
|
||||||
|
'variant_count': len(part_ids),
|
||||||
|
'sample_image': sample_image,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_groups(master_conn, mye_id, category_id):
|
def get_groups(master_conn, mye_id, category_id):
|
||||||
"""Get part groups (subcategories) for this vehicle + category, with part counts."""
|
"""Get part groups (subcategories) for this vehicle + category, with part counts."""
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
@@ -209,16 +715,62 @@ def get_groups(master_conn, mye_id, category_id):
|
|||||||
return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
|
return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_part_types(master_conn, mye_id, group_id):
|
||||||
|
"""Get distinct part types within a vehicle+group (Nexpart-style 3rd subcategory level).
|
||||||
|
|
||||||
|
A "part type" is a unique part name within a group — e.g. within "Brake System"
|
||||||
|
group, the types might be "Brake Disc", "Brake Pad", "Brake Caliper", each with
|
||||||
|
multiple OEM/aftermarket variants.
|
||||||
|
|
||||||
|
Returns: [{name, slug, variant_count, sample_image}]
|
||||||
|
- name: display name (Spanish if available, else original)
|
||||||
|
- slug: URL-safe key used to filter parts (the original English name_part)
|
||||||
|
- variant_count: how many distinct OEM parts exist for this type
|
||||||
|
- sample_image: image URL of the first variant (for thumbnail)
|
||||||
|
"""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
# Use ORIGINAL name_part as the slug (matches DB column for filtering),
|
||||||
|
# but display the Spanish translation if available.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
p.name_part AS slug,
|
||||||
|
COALESCE(p.name_es, p.name_part) AS display_name,
|
||||||
|
COUNT(*) AS variants,
|
||||||
|
(ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
WHERE vp.model_year_engine_id = %s
|
||||||
|
AND p.group_id = %s
|
||||||
|
GROUP BY p.name_part, COALESCE(p.name_es, p.name_part)
|
||||||
|
ORDER BY variants DESC, display_name ASC
|
||||||
|
""", (mye_id, group_id))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'slug': r[0],
|
||||||
|
'name': translate_part_name(r[1]),
|
||||||
|
'variant_count': r[2],
|
||||||
|
'sample_image': r[3],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# PARTS LIST + DETAIL (with stock enrichment)
|
# PARTS LIST + DETAIL (with stock enrichment)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30):
|
def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30, part_type=None):
|
||||||
"""Get parts for a vehicle + part group, enriched with local stock + bodega indicator.
|
"""Get parts for a vehicle + part group, enriched with local stock + bodega indicator.
|
||||||
|
|
||||||
1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated
|
1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated
|
||||||
2. For each OEM number, look up tenant inventory for local stock
|
2. For each OEM number, look up tenant inventory for local stock
|
||||||
3. For each part_id, check warehouse_inventory for bodega availability
|
3. For each part_id, check warehouse_inventory for bodega availability
|
||||||
|
|
||||||
|
Optional part_type filter (string): when provided, only returns parts whose
|
||||||
|
name_part matches exactly. Used for the 3rd subcategory level (Nexpart-style).
|
||||||
|
|
||||||
Returns: {data: [...], pagination: {...}}
|
Returns: {data: [...], pagination: {...}}
|
||||||
"""
|
"""
|
||||||
per_page = min(per_page, 100)
|
per_page = min(per_page, 100)
|
||||||
@@ -226,13 +778,20 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
|
|||||||
|
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
|
extra_where = ""
|
||||||
|
extra_params_count = (mye_id, group_id)
|
||||||
|
extra_params_fetch = (mye_id, group_id, per_page, offset)
|
||||||
|
if part_type:
|
||||||
|
extra_where = " AND p.name_part = %s"
|
||||||
|
extra_params_count = (mye_id, group_id, part_type)
|
||||||
|
extra_params_fetch = (mye_id, group_id, part_type, per_page, offset)
|
||||||
|
|
||||||
# Count total (bounded — uses indexed mye_id + group_id join)
|
# Count total (bounded — uses indexed mye_id + group_id join)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM vehicle_parts vp
|
FROM vehicle_parts vp
|
||||||
JOIN parts p ON p.id_part = vp.part_id
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
|
WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where, extra_params_count)
|
||||||
""", (mye_id, group_id))
|
|
||||||
total = cur.fetchone()[0]
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
# Fetch page of parts
|
# Fetch page of parts
|
||||||
@@ -241,10 +800,10 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
|
|||||||
p.description, p.description_es, p.image_url
|
p.description, p.description_es, p.image_url
|
||||||
FROM vehicle_parts vp
|
FROM vehicle_parts vp
|
||||||
JOIN parts p ON p.id_part = vp.part_id
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
|
WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where + """
|
||||||
ORDER BY p.name_part
|
ORDER BY p.name_part
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""", (mye_id, group_id, per_page, offset))
|
""", extra_params_fetch)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -289,6 +848,185 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
|
|||||||
return {'data': items, 'pagination': _pagination(page, per_page, total)}
|
return {'data': items, 'pagination': _pagination(page, per_page, total)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
|
||||||
|
page=1, per_page=30, part_type=None, oem_part_ids=None):
|
||||||
|
"""Local catalog mode: show aftermarket parts instead of OEM.
|
||||||
|
|
||||||
|
Two filtering modes:
|
||||||
|
A) `oem_part_ids` provided → fetch aftermarket equivalents for that
|
||||||
|
specific list of OEM IDs. Used by get_parts_for_nexpart_triple()
|
||||||
|
(Nexpart navigation in Local mode).
|
||||||
|
B) `oem_part_ids` is None → use (mye_id, group_id, optional part_type)
|
||||||
|
to find OEM parts via vehicle_parts join. Legacy path for the
|
||||||
|
TecDoc-style Local navigation.
|
||||||
|
|
||||||
|
Flow (mode B; mode A skips step 1 since IDs are already known):
|
||||||
|
1. Find OEM parts for the vehicle+group.
|
||||||
|
2. For each OEM part, pull all aftermarket equivalents.
|
||||||
|
3. Join manufacturers to get brand name.
|
||||||
|
4. Join warehouse_inventory to check bodega availability.
|
||||||
|
5. Sort by priority tier, then in-stock first, then manufacturer name.
|
||||||
|
6. Paginate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{data: [...], pagination: {...}, mode: 'local'}
|
||||||
|
Each part item: manufacturer, priority_tier, in_stock_network,
|
||||||
|
warehouse_price, plus the standard fields.
|
||||||
|
"""
|
||||||
|
from services.catalog_modes import (
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER1,
|
||||||
|
LOCAL_PRIORITY_MANUFACTURERS_TIER2,
|
||||||
|
get_priority_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
per_page = min(per_page, 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
|
tier1 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER1)
|
||||||
|
tier2 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER2)
|
||||||
|
|
||||||
|
# ─── Build the WHERE clause for the OEM-side filter ───
|
||||||
|
if oem_part_ids is not None:
|
||||||
|
# Mode A: explicit OEM ID list (Nexpart navigation)
|
||||||
|
where_clause = "p.id_part = ANY(%s)"
|
||||||
|
where_params_count = (oem_part_ids,)
|
||||||
|
from_join_count = """
|
||||||
|
FROM parts p
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
# Mode B: vehicle+group filter (legacy TecDoc navigation)
|
||||||
|
from_join_count = """
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON p.id_part = vp.part_id
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
"""
|
||||||
|
where_clause = "vp.model_year_engine_id = %s AND p.group_id = %s"
|
||||||
|
where_params_count = (mye_id, group_id)
|
||||||
|
if part_type:
|
||||||
|
where_clause += " AND p.name_part = %s"
|
||||||
|
where_params_count = (mye_id, group_id, part_type)
|
||||||
|
|
||||||
|
# Count total aftermarket parts
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) " + from_join_count + " WHERE " + where_clause,
|
||||||
|
where_params_count,
|
||||||
|
)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Priority-sorted fetch — same WHERE clause as the COUNT, plus tiers + paging.
|
||||||
|
fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset]
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
WITH aftermarket_for_vehicle AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
ap.id_aftermarket_parts,
|
||||||
|
ap.oem_part_id,
|
||||||
|
ap.part_number,
|
||||||
|
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name,
|
||||||
|
ap.price_usd,
|
||||||
|
m.name_manufacture,
|
||||||
|
p.oem_part_number,
|
||||||
|
COALESCE(p.name_es, p.name_part) AS oem_name,
|
||||||
|
COALESCE(p.description_es, p.description) AS oem_desc,
|
||||||
|
p.image_url AS oem_image
|
||||||
|
""" + from_join_count + """
|
||||||
|
WHERE """ + where_clause + """
|
||||||
|
),
|
||||||
|
stock_per_oem AS (
|
||||||
|
SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock
|
||||||
|
FROM warehouse_inventory
|
||||||
|
WHERE stock_quantity > 0
|
||||||
|
GROUP BY part_id
|
||||||
|
)
|
||||||
|
SELECT afv.id_aftermarket_parts,
|
||||||
|
afv.oem_part_id,
|
||||||
|
afv.part_number,
|
||||||
|
afv.am_name,
|
||||||
|
afv.price_usd,
|
||||||
|
afv.name_manufacture,
|
||||||
|
afv.oem_part_number,
|
||||||
|
afv.oem_name,
|
||||||
|
afv.oem_desc,
|
||||||
|
afv.oem_image,
|
||||||
|
COALESCE(s.bodega_count, 0) AS bodega_count,
|
||||||
|
s.min_price AS warehouse_price,
|
||||||
|
COALESCE(s.total_stock, 0) AS warehouse_stock,
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1
|
||||||
|
WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS tier
|
||||||
|
FROM aftermarket_for_vehicle afv
|
||||||
|
LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id
|
||||||
|
ORDER BY tier ASC,
|
||||||
|
(COALESCE(s.bodega_count, 0) > 0) DESC,
|
||||||
|
afv.name_manufacture ASC,
|
||||||
|
afv.am_name ASC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", fetch_params)
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {'data': [], 'pagination': _pagination(page, per_page, total), 'mode': 'local'}
|
||||||
|
|
||||||
|
# Enrich with tenant local stock (look up by OEM part number).
|
||||||
|
# Use a different name to avoid shadowing the `oem_part_ids` parameter.
|
||||||
|
oem_numbers = list({r[6] for r in rows if r[6]})
|
||||||
|
result_oem_ids = list({r[1] for r in rows if r[1]})
|
||||||
|
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
aft_id = r[0]
|
||||||
|
oem_part_id = r[1]
|
||||||
|
aft_number = r[2]
|
||||||
|
aft_name = r[3]
|
||||||
|
price_usd = r[4]
|
||||||
|
manufacturer = r[5]
|
||||||
|
oem_number = r[6]
|
||||||
|
oem_name = r[7]
|
||||||
|
oem_desc = r[8]
|
||||||
|
oem_image = r[9]
|
||||||
|
bodega_count = r[10]
|
||||||
|
warehouse_price = r[11]
|
||||||
|
warehouse_stock = r[12]
|
||||||
|
tier = r[13]
|
||||||
|
|
||||||
|
# Tenant local stock (refaccionaria's own inventory)
|
||||||
|
local = local_map.get(oem_number) or local_map.get(f'cat:{oem_part_id}')
|
||||||
|
image_url = (local.get('image_url') if local else None) or oem_image
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
# Keep fields compatible with OEM mode output so the frontend
|
||||||
|
# can render with minimal branching.
|
||||||
|
'id_part': oem_part_id, # OEM id used for detail drill-down
|
||||||
|
'id_aftermarket': aft_id, # aftermarket row id (for future use)
|
||||||
|
'oem_part_number': oem_number,
|
||||||
|
'part_number': aft_number, # aftermarket SKU
|
||||||
|
'name': translate_part_name(aft_name or oem_name),
|
||||||
|
'description': oem_desc,
|
||||||
|
'image_url': image_url,
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'priority_tier': tier, # 1, 2, or 3
|
||||||
|
'local_stock': local['stock'] if local else 0,
|
||||||
|
'local_price': local['price_1'] if local else None,
|
||||||
|
'bodega_count': bodega_count,
|
||||||
|
'warehouse_stock': warehouse_stock,
|
||||||
|
'warehouse_price': float(warehouse_price) if warehouse_price is not None else None,
|
||||||
|
'in_stock_network': bodega_count > 0,
|
||||||
|
'price_usd': float(price_usd) if price_usd is not None else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'data': items, 'pagination': _pagination(page, per_page, total), 'mode': 'local'}
|
||||||
|
|
||||||
|
|
||||||
def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
|
def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
|
||||||
"""Get full detail for a single part: catalog info, local stock, bodegas, alternatives.
|
"""Get full detail for a single part: catalog info, local stock, bodegas, alternatives.
|
||||||
|
|
||||||
@@ -538,7 +1276,13 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids)
|
|||||||
|
|
||||||
Returns: dict keyed by oem_number (or 'cat:{id}') -> {stock, price_1, ...}
|
Returns: dict keyed by oem_number (or 'cat:{id}') -> {stock, price_1, ...}
|
||||||
Matches by: part_number = oem_number OR catalog_part_id = id
|
Matches by: part_number = oem_number OR catalog_part_id = id
|
||||||
|
|
||||||
|
Public-catalog-safe: when tenant_conn is None (public browsing, no tenant
|
||||||
|
context) returns an empty dict so the parts list still renders without
|
||||||
|
local stock/price enrichment.
|
||||||
"""
|
"""
|
||||||
|
if tenant_conn is None:
|
||||||
|
return {}
|
||||||
if not oem_numbers and not catalog_part_ids:
|
if not oem_numbers and not catalog_part_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
810
pos/services/marketplace_service.py
Normal file
810
pos/services/marketplace_service.py
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
"""
|
||||||
|
Marketplace B2B — service layer for bodegas, warehouse inventory and
|
||||||
|
Purchase Orders (Phase 1).
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
draft → submitted → confirmed → ready → delivered → closed
|
||||||
|
↘ rejected (terminal)
|
||||||
|
|
||||||
|
Public API is grouped by concern:
|
||||||
|
- Bodegas: list_bodegas, get_bodega, verify_bodega
|
||||||
|
- Inventory: upload_inventory_csv, search_inventory
|
||||||
|
- POs: create_po_draft, submit_po, transition_po,
|
||||||
|
get_po_detail, list_pos_for_buyer, list_pos_for_seller
|
||||||
|
- Notifications: notify_po_status_change (used internally by transition_po)
|
||||||
|
|
||||||
|
All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts).
|
||||||
|
The caller is responsible for committing and closing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# STATE MACHINE
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed')
|
||||||
|
|
||||||
|
# Map: current_status → {new_status: {actor_kinds}}
|
||||||
|
# 'buyer' = user who created the PO; 'seller' = bodega owner/user
|
||||||
|
PO_TRANSITIONS = {
|
||||||
|
'draft': {'submitted': {'buyer'}},
|
||||||
|
'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}},
|
||||||
|
'confirmed': {'ready': {'seller'}},
|
||||||
|
'ready': {'delivered': {'buyer', 'seller'}},
|
||||||
|
'delivered': {'closed': {'buyer', 'seller'}},
|
||||||
|
# terminal: rejected, closed
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool:
|
||||||
|
allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status)
|
||||||
|
if not allowed:
|
||||||
|
return False
|
||||||
|
return actor_kind in allowed
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# BODEGAS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]:
|
||||||
|
"""Return all bodegas, optionally filtered."""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
clauses = []
|
||||||
|
params = []
|
||||||
|
if verified_only:
|
||||||
|
clauses.append("verified = TRUE")
|
||||||
|
if city:
|
||||||
|
clauses.append("LOWER(city) = LOWER(%s)")
|
||||||
|
params.append(city)
|
||||||
|
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified
|
||||||
|
FROM bodegas
|
||||||
|
{where}
|
||||||
|
ORDER BY name
|
||||||
|
""", params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
|
||||||
|
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
|
||||||
|
'verified': r[7],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_bodega(master_conn, bodega_id: int) -> Optional[dict]:
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state,
|
||||||
|
address, verified, commission_pct
|
||||||
|
FROM bodegas WHERE id_bodega = %s
|
||||||
|
""", (bodega_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
|
||||||
|
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
|
||||||
|
'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_bodega(master_conn, *, name: str, whatsapp_phone: str,
|
||||||
|
owner_name: str = None, email: str = None,
|
||||||
|
city: str = None, state: str = None, address: str = None) -> int:
|
||||||
|
"""Register a new bodega (unverified by default). Admin verifies later."""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id_bodega
|
||||||
|
""", (name, owner_name, whatsapp_phone, email, city, state, address))
|
||||||
|
bodega_id = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
return bodega_id
|
||||||
|
|
||||||
|
|
||||||
|
def verify_bodega(master_conn, bodega_id: int) -> bool:
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s
|
||||||
|
""", (bodega_id,))
|
||||||
|
ok = cur.rowcount > 0
|
||||||
|
cur.close()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# INVENTORY — warehouse_inventory CSV upload + search
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||||
|
"""Bulk-upload a bodega's inventory from a CSV string.
|
||||||
|
|
||||||
|
Expected columns (case-insensitive, whitespace-tolerant):
|
||||||
|
part_number, stock, price
|
||||||
|
Optional:
|
||||||
|
min_order, warehouse_location, currency
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
|
||||||
|
- Parts not found in the master catalog are skipped and reported.
|
||||||
|
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
|
||||||
|
via UPSERT; new rows are inserted.
|
||||||
|
|
||||||
|
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
||||||
|
"""
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
# Normalize header names
|
||||||
|
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
|
||||||
|
|
||||||
|
required = {'part_number', 'stock', 'price'}
|
||||||
|
missing = required - set(fieldnames)
|
||||||
|
if missing:
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}',
|
||||||
|
'inserted': 0, 'updated': 0, 'skipped': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve bodega → its legacy user_id (warehouse_inventory still requires it)
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.close()
|
||||||
|
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
||||||
|
norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()}
|
||||||
|
part_number = norm.get('part_number', '')
|
||||||
|
stock_str = norm.get('stock', '0')
|
||||||
|
price_str = norm.get('price', '0')
|
||||||
|
|
||||||
|
if not part_number:
|
||||||
|
errors.append(f'Fila {i}: part_number vacio')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
stock = int(stock_str)
|
||||||
|
price = float(price_str)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f'Fila {i}: stock o price invalido')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve part_number → part_id
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
||||||
|
(part_number,)
|
||||||
|
)
|
||||||
|
row_part = cur.fetchone()
|
||||||
|
if not row_part:
|
||||||
|
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
part_id = row_part[0]
|
||||||
|
|
||||||
|
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
||||||
|
user_id = norm.get('user_id') or bodega_id # backward compat
|
||||||
|
try:
|
||||||
|
user_id = int(user_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
user_id = bodega_id
|
||||||
|
|
||||||
|
location = norm.get('warehouse_location') or 'Principal'
|
||||||
|
currency = (norm.get('currency') or 'MXN').upper()
|
||||||
|
min_order = int(norm.get('min_order') or 1)
|
||||||
|
|
||||||
|
# UPSERT on (user_id, part_id, warehouse_location) — the existing
|
||||||
|
# unique constraint. Don't block if user_id FK fails.
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO warehouse_inventory
|
||||||
|
(user_id, part_id, price, stock_quantity, min_order_quantity,
|
||||||
|
warehouse_location, bodega_id, currency, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (user_id, part_id, warehouse_location)
|
||||||
|
DO UPDATE SET
|
||||||
|
price = EXCLUDED.price,
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||||
|
bodega_id = EXCLUDED.bodega_id,
|
||||||
|
currency = EXCLUDED.currency,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||||
|
was_insert = cur.fetchone()[0]
|
||||||
|
if was_insert:
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Fila {i}: DB error: {str(e)[:100]}')
|
||||||
|
skipped += 1
|
||||||
|
master_conn.rollback() # so next INSERTs can proceed
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
master_conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'inserted': inserted,
|
||||||
|
'updated': updated,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors[:20], # cap to avoid huge responses
|
||||||
|
'total_errors': len(errors),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_inventory(master_conn, *, query: str = None, brand: str = None,
|
||||||
|
city: str = None, limit: int = 50) -> list[dict]:
|
||||||
|
"""Browse warehouse_inventory filtered by query / brand / city.
|
||||||
|
|
||||||
|
Returns parts WITH stock > 0 from VERIFIED bodegas only.
|
||||||
|
Aggregates identical parts across bodegas so the buyer sees each part once
|
||||||
|
with a list of bodegas that have it in stock.
|
||||||
|
"""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
|
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if query:
|
||||||
|
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||||
|
like = f'%{query}%'
|
||||||
|
params.extend([like, like, like])
|
||||||
|
|
||||||
|
if brand:
|
||||||
|
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
|
||||||
|
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
|
||||||
|
clauses.append("""
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM aftermarket_parts ap
|
||||||
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
params.append(brand)
|
||||||
|
|
||||||
|
if city:
|
||||||
|
clauses.append("LOWER(b.city) = LOWER(%s)")
|
||||||
|
params.append(city)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(clauses)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
p.id_part,
|
||||||
|
p.oem_part_number,
|
||||||
|
COALESCE(p.name_es, p.name_part) AS name,
|
||||||
|
p.image_url,
|
||||||
|
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||||
|
MIN(wi.price) AS min_price,
|
||||||
|
MAX(wi.price) AS max_price,
|
||||||
|
SUM(wi.stock_quantity) AS total_stock,
|
||||||
|
-- List of bodega names that have this part in stock
|
||||||
|
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
|
||||||
|
FROM warehouse_inventory wi
|
||||||
|
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||||
|
JOIN parts p ON p.id_part = wi.part_id
|
||||||
|
WHERE {where_sql}
|
||||||
|
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||||
|
ORDER BY total_stock DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", params + [limit])
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_part': r[0],
|
||||||
|
'oem_part_number': r[1],
|
||||||
|
'name': r[2],
|
||||||
|
'image_url': r[3],
|
||||||
|
'bodega_count': r[4],
|
||||||
|
'min_price': float(r[5]) if r[5] is not None else None,
|
||||||
|
'max_price': float(r[6]) if r[6] is not None else None,
|
||||||
|
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
|
||||||
|
'bodega_names': r[8], # may expose; adjust if sensitive
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
|
||||||
|
"""Return the list of verified bodegas that currently have a given OEM part
|
||||||
|
in stock. Used when the buyer wants to pick WHICH bodega to order from.
|
||||||
|
"""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
|
||||||
|
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
|
||||||
|
FROM warehouse_inventory wi
|
||||||
|
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||||
|
WHERE wi.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
|
||||||
|
ORDER BY wi.price ASC
|
||||||
|
""", (part_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
|
||||||
|
'price': float(r[4]) if r[4] is not None else None,
|
||||||
|
'stock_hint': 'En stock', # don't expose exact quantity
|
||||||
|
'min_order': r[6] or 1,
|
||||||
|
'currency': r[7] or 'MXN',
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# PURCHASE ORDERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||||
|
buyer_display_name: str, buyer_phone: str, buyer_email: str,
|
||||||
|
bodega_id: int, items: list,
|
||||||
|
delivery_method: str = 'pickup',
|
||||||
|
delivery_address: str = None,
|
||||||
|
buyer_notes: str = None) -> int:
|
||||||
|
"""Create a PO in 'draft' status with its items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: list of dicts with keys: part_id, quantity, unit_price (optional)
|
||||||
|
If unit_price is missing, it's pulled from warehouse_inventory.
|
||||||
|
|
||||||
|
Returns the new po_id.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
raise ValueError('A PO must have at least one item')
|
||||||
|
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
|
# Create header
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO purchase_orders (
|
||||||
|
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
|
||||||
|
bodega_id, status, delivery_method, delivery_address, buyer_notes
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s)
|
||||||
|
RETURNING id_po
|
||||||
|
""", (
|
||||||
|
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
|
||||||
|
bodega_id, delivery_method, delivery_address, buyer_notes,
|
||||||
|
))
|
||||||
|
po_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Insert items
|
||||||
|
total = 0.0
|
||||||
|
for item in items:
|
||||||
|
part_id = int(item['part_id'])
|
||||||
|
quantity = int(item['quantity'])
|
||||||
|
if quantity < 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lookup part info + price
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||||
|
FROM parts p
|
||||||
|
LEFT JOIN warehouse_inventory wi
|
||||||
|
ON wi.part_id = p.id_part AND wi.bodega_id = %s
|
||||||
|
WHERE p.id_part = %s LIMIT 1
|
||||||
|
""", (bodega_id, part_id))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
oem, name, db_price = r
|
||||||
|
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||||
|
subtotal = round(unit_price * quantity, 2)
|
||||||
|
total += subtotal
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO purchase_order_items
|
||||||
|
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||||
|
|
||||||
|
# Update header total
|
||||||
|
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||||
|
(round(total, 2), po_id))
|
||||||
|
|
||||||
|
# Log initial status
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note)
|
||||||
|
VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado')
|
||||||
|
""", (po_id, buyer_user_id))
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
master_conn.commit()
|
||||||
|
return po_id
|
||||||
|
|
||||||
|
|
||||||
|
def transition_po(master_conn, *, po_id: int, new_status: str,
|
||||||
|
actor_user_id: int, actor_kind: str,
|
||||||
|
note: str = None) -> dict:
|
||||||
|
"""Transition a PO to a new status with full validation and notification.
|
||||||
|
|
||||||
|
Returns: {ok, from_status, to_status, notified} or {ok: False, error}
|
||||||
|
"""
|
||||||
|
if new_status not in PO_STATUSES:
|
||||||
|
return {'ok': False, 'error': f'Invalid status: {new_status}'}
|
||||||
|
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
return {'ok': False, 'error': f'PO {po_id} not found'}
|
||||||
|
|
||||||
|
from_status = row[0]
|
||||||
|
if not _is_valid_transition(from_status, new_status, actor_kind):
|
||||||
|
cur.close()
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'error': f'Transition {from_status}→{new_status} not allowed for {actor_kind}',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Timestamp columns per state
|
||||||
|
ts_field = {
|
||||||
|
'submitted': 'submitted_at',
|
||||||
|
'confirmed': 'confirmed_at',
|
||||||
|
'ready': 'ready_at',
|
||||||
|
'delivered': 'delivered_at',
|
||||||
|
'closed': 'closed_at',
|
||||||
|
}.get(new_status)
|
||||||
|
|
||||||
|
if ts_field:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s",
|
||||||
|
(new_status, po_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s",
|
||||||
|
(new_status, po_id))
|
||||||
|
|
||||||
|
# Log history row
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO po_status_history
|
||||||
|
(po_id, from_status, to_status, actor_user_id, actor_kind, note)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""", (po_id, from_status, new_status, actor_user_id, actor_kind, note))
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
master_conn.commit()
|
||||||
|
|
||||||
|
# Fire notifications — non-blocking (failures logged, not raised)
|
||||||
|
notified = []
|
||||||
|
try:
|
||||||
|
notified = notify_po_status_change(master_conn, po_id, new_status)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[marketplace] notification failed for PO {po_id}: {e}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'from_status': from_status,
|
||||||
|
'to_status': new_status,
|
||||||
|
'notified': notified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_po_detail(master_conn, po_id: int) -> Optional[dict]:
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name,
|
||||||
|
po.buyer_phone, po.buyer_email,
|
||||||
|
po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone,
|
||||||
|
b.email AS bodega_email,
|
||||||
|
po.status, po.total_amount, po.currency,
|
||||||
|
po.buyer_notes, po.seller_notes,
|
||||||
|
po.delivery_method, po.delivery_address,
|
||||||
|
po.created_at, po.submitted_at, po.confirmed_at, po.ready_at,
|
||||||
|
po.delivered_at, po.closed_at
|
||||||
|
FROM purchase_orders po
|
||||||
|
JOIN bodegas b ON b.id_bodega = po.bodega_id
|
||||||
|
WHERE po.id_po = %s
|
||||||
|
""", (po_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
cur.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
po = {
|
||||||
|
'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2],
|
||||||
|
'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5],
|
||||||
|
'bodega_id': r[6], 'bodega_name': r[7],
|
||||||
|
'bodega_phone': r[8], 'bodega_email': r[9],
|
||||||
|
'status': r[10],
|
||||||
|
'total_amount': float(r[11]) if r[11] is not None else 0.0,
|
||||||
|
'currency': r[12],
|
||||||
|
'buyer_notes': r[13], 'seller_notes': r[14],
|
||||||
|
'delivery_method': r[15], 'delivery_address': r[16],
|
||||||
|
'created_at': r[17].isoformat() if r[17] else None,
|
||||||
|
'submitted_at': r[18].isoformat() if r[18] else None,
|
||||||
|
'confirmed_at': r[19].isoformat() if r[19] else None,
|
||||||
|
'ready_at': r[20].isoformat() if r[20] else None,
|
||||||
|
'delivered_at': r[21].isoformat() if r[21] else None,
|
||||||
|
'closed_at': r[22].isoformat() if r[22] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Items
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer,
|
||||||
|
quantity, unit_price, subtotal, confirmed_qty, notes
|
||||||
|
FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item
|
||||||
|
""", (po_id,))
|
||||||
|
po['items'] = [
|
||||||
|
{
|
||||||
|
'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2],
|
||||||
|
'part_name': ir[3], 'manufacturer': ir[4],
|
||||||
|
'quantity': ir[5],
|
||||||
|
'unit_price': float(ir[6]) if ir[6] is not None else 0.0,
|
||||||
|
'subtotal': float(ir[7]) if ir[7] is not None else 0.0,
|
||||||
|
'confirmed_qty': ir[8],
|
||||||
|
'notes': ir[9],
|
||||||
|
}
|
||||||
|
for ir in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Status history
|
||||||
|
cur.execute("""
|
||||||
|
SELECT from_status, to_status, actor_kind, note, created_at
|
||||||
|
FROM po_status_history WHERE po_id = %s ORDER BY created_at
|
||||||
|
""", (po_id,))
|
||||||
|
po['history'] = [
|
||||||
|
{
|
||||||
|
'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2],
|
||||||
|
'note': h[3], 'at': h[4].isoformat() if h[4] else None,
|
||||||
|
}
|
||||||
|
for h in cur.fetchall()
|
||||||
|
]
|
||||||
|
cur.close()
|
||||||
|
return po
|
||||||
|
|
||||||
|
|
||||||
|
def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None,
|
||||||
|
limit: int = 50) -> list[dict]:
|
||||||
|
"""Return POs created by a buyer (filtered by tenant or user)."""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
clauses = ['po.buyer_tenant_id = %s']
|
||||||
|
params = [buyer_tenant_id]
|
||||||
|
if buyer_user_id is not None:
|
||||||
|
clauses.append('po.buyer_user_id = %s')
|
||||||
|
params.append(buyer_user_id)
|
||||||
|
where = ' AND '.join(clauses)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT po.id_po, po.status, po.total_amount, po.currency,
|
||||||
|
po.bodega_id, b.name AS bodega_name,
|
||||||
|
po.created_at, po.submitted_at,
|
||||||
|
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
|
||||||
|
FROM purchase_orders po
|
||||||
|
JOIN bodegas b ON b.id_bodega = po.bodega_id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY po.created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", params + [limit])
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_po': r[0], 'status': r[1],
|
||||||
|
'total_amount': float(r[2]) if r[2] is not None else 0.0,
|
||||||
|
'currency': r[3],
|
||||||
|
'bodega_id': r[4], 'bodega_name': r[5],
|
||||||
|
'created_at': r[6].isoformat() if r[6] else None,
|
||||||
|
'submitted_at': r[7].isoformat() if r[7] else None,
|
||||||
|
'item_count': r[8],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]:
|
||||||
|
"""Inbox: POs addressed to a seller (bodega)."""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT po.id_po, po.status, po.total_amount, po.currency,
|
||||||
|
po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone,
|
||||||
|
po.created_at, po.submitted_at,
|
||||||
|
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
|
||||||
|
FROM purchase_orders po
|
||||||
|
WHERE po.bodega_id = %s AND po.status != 'draft'
|
||||||
|
ORDER BY
|
||||||
|
CASE po.status
|
||||||
|
WHEN 'submitted' THEN 1
|
||||||
|
WHEN 'confirmed' THEN 2
|
||||||
|
WHEN 'ready' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
po.submitted_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (bodega_id, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id_po': r[0], 'status': r[1],
|
||||||
|
'total_amount': float(r[2]) if r[2] is not None else 0.0,
|
||||||
|
'currency': r[3],
|
||||||
|
'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6],
|
||||||
|
'created_at': r[7].isoformat() if r[7] else None,
|
||||||
|
'submitted_at': r[8].isoformat() if r[8] else None,
|
||||||
|
'item_count': r[9],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# NOTIFICATIONS — WhatsApp + Email
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Per-status message templates. Each is a (subject, body) tuple.
|
||||||
|
# The body is plain text — same text goes to WA and email, with an optional
|
||||||
|
# HTML wrapper for email.
|
||||||
|
_PO_MESSAGE_TEMPLATES = {
|
||||||
|
'submitted': (
|
||||||
|
'Nuevo pedido Nexus #{po_id}',
|
||||||
|
'Tienes un nuevo pedido en Nexus Marketplace.\n\n'
|
||||||
|
'Pedido: #{po_id}\n'
|
||||||
|
'Comprador: {buyer_display_name}\n'
|
||||||
|
'Total: ${total_amount:,.2f} {currency}\n'
|
||||||
|
'Items: {item_count}\n\n'
|
||||||
|
'Entra al marketplace para confirmar o rechazar.'
|
||||||
|
),
|
||||||
|
'confirmed': (
|
||||||
|
'Pedido #{po_id} confirmado por {bodega_name}',
|
||||||
|
'Tu pedido fue confirmado.\n\n'
|
||||||
|
'Pedido: #{po_id}\n'
|
||||||
|
'Bodega: {bodega_name}\n'
|
||||||
|
'Total: ${total_amount:,.2f} {currency}\n\n'
|
||||||
|
'Te avisaremos cuando este listo para recoger / entregar.'
|
||||||
|
),
|
||||||
|
'rejected': (
|
||||||
|
'Pedido #{po_id} rechazado',
|
||||||
|
'Tu pedido fue rechazado por {bodega_name}.\n\n'
|
||||||
|
'Pedido: #{po_id}\n'
|
||||||
|
'Puedes intentar con otra bodega en el marketplace.'
|
||||||
|
),
|
||||||
|
'ready': (
|
||||||
|
'Pedido #{po_id} listo',
|
||||||
|
'Tu pedido esta listo.\n\n'
|
||||||
|
'Pedido: #{po_id}\n'
|
||||||
|
'Bodega: {bodega_name}\n'
|
||||||
|
'Metodo: {delivery_method}\n\n'
|
||||||
|
'Pasa a recogerlo o espera la entrega.'
|
||||||
|
),
|
||||||
|
'delivered': (
|
||||||
|
'Pedido #{po_id} entregado',
|
||||||
|
'El pedido #{po_id} fue marcado como entregado.\n'
|
||||||
|
'Gracias por usar Nexus Marketplace.'
|
||||||
|
),
|
||||||
|
'closed': (
|
||||||
|
'Pedido #{po_id} cerrado',
|
||||||
|
'El pedido #{po_id} fue cerrado.'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]:
|
||||||
|
"""Send WhatsApp + email notification about a PO status change.
|
||||||
|
|
||||||
|
Returns a list of channel names that were successfully notified
|
||||||
|
(e.g. ['whatsapp', 'email']). Failures are logged but not raised.
|
||||||
|
"""
|
||||||
|
template = _PO_MESSAGE_TEMPLATES.get(new_status)
|
||||||
|
if not template:
|
||||||
|
return [] # no message defined for this status
|
||||||
|
|
||||||
|
po = get_po_detail(master_conn, po_id)
|
||||||
|
if not po:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Resolve context variables for the template
|
||||||
|
ctx = {
|
||||||
|
'po_id': po_id,
|
||||||
|
'buyer_display_name': po.get('buyer_display_name') or 'Cliente',
|
||||||
|
'bodega_name': po.get('bodega_name') or 'Bodega',
|
||||||
|
'total_amount': po.get('total_amount') or 0,
|
||||||
|
'currency': po.get('currency') or 'MXN',
|
||||||
|
'delivery_method': po.get('delivery_method') or 'pickup',
|
||||||
|
'item_count': len(po.get('items') or []),
|
||||||
|
}
|
||||||
|
subject_tpl, body_tpl = template
|
||||||
|
try:
|
||||||
|
subject = subject_tpl.format(**ctx)
|
||||||
|
body = body_tpl.format(**ctx)
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
print(f'[marketplace] template format error for {new_status}: {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Decide the recipient based on who should be notified for this status
|
||||||
|
# - submitted → notify seller (new order arrived)
|
||||||
|
# - confirmed/rejected/ready → notify buyer (status update)
|
||||||
|
# - delivered → notify both (handled as 2 sends)
|
||||||
|
# - closed → notify buyer
|
||||||
|
recipients = []
|
||||||
|
if new_status == 'submitted':
|
||||||
|
recipients = [{
|
||||||
|
'kind': 'seller',
|
||||||
|
'phone': po.get('bodega_phone'),
|
||||||
|
'email': po.get('bodega_email'),
|
||||||
|
}]
|
||||||
|
elif new_status in ('confirmed', 'rejected', 'ready', 'closed'):
|
||||||
|
recipients = [{
|
||||||
|
'kind': 'buyer',
|
||||||
|
'phone': po.get('buyer_phone'),
|
||||||
|
'email': po.get('buyer_email'),
|
||||||
|
}]
|
||||||
|
elif new_status == 'delivered':
|
||||||
|
recipients = [
|
||||||
|
{'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')},
|
||||||
|
{'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')},
|
||||||
|
]
|
||||||
|
|
||||||
|
channels_used = []
|
||||||
|
for recipient in recipients:
|
||||||
|
# WhatsApp
|
||||||
|
if recipient.get('phone'):
|
||||||
|
try:
|
||||||
|
from services import whatsapp_service
|
||||||
|
result = whatsapp_service.send_message(recipient['phone'], body)
|
||||||
|
if result and not result.get('error'):
|
||||||
|
channels_used.append(f"whatsapp:{recipient['kind']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[marketplace] WA send failed: {e}')
|
||||||
|
|
||||||
|
# Email
|
||||||
|
if recipient.get('email'):
|
||||||
|
try:
|
||||||
|
sent = _send_email(recipient['email'], subject, body)
|
||||||
|
if sent:
|
||||||
|
channels_used.append(f"email:{recipient['kind']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[marketplace] email send failed: {e}')
|
||||||
|
|
||||||
|
return channels_used
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email(to_email: str, subject: str, body_text: str) -> bool:
|
||||||
|
"""Send a plain-text email via SMTP (config in pos/config.py).
|
||||||
|
|
||||||
|
Returns True if the mail was actually sent, False if SMTP is not
|
||||||
|
configured (silent no-op so dev environments don't crash).
|
||||||
|
"""
|
||||||
|
import config
|
||||||
|
if not config.SMTP_USER or not config.SMTP_PASS:
|
||||||
|
print('[marketplace] SMTP not configured — skipping email')
|
||||||
|
return False
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['From'] = config.SMTP_FROM
|
||||||
|
msg['To'] = to_email
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
|
||||||
|
|
||||||
|
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(config.SMTP_USER, config.SMTP_PASS)
|
||||||
|
server.send_message(msg)
|
||||||
|
print(f'[marketplace] email sent to {to_email}: {subject}')
|
||||||
|
return True
|
||||||
745
pos/services/nexpart_taxonomy.py
Normal file
745
pos/services/nexpart_taxonomy.py
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
"""
|
||||||
|
Nexpart Taxonomy — Universal parts classification used in Local catalog mode.
|
||||||
|
|
||||||
|
Source of truth: /home/Autopartes/CapturasWeb/nexpart_hierarchy.txt
|
||||||
|
Total: 14 Groups → 103 Subgroups → 558 Part Types
|
||||||
|
|
||||||
|
This module loads the Nexpart hierarchy from the .txt file and provides
|
||||||
|
helpers to:
|
||||||
|
1. List all groups / subgroups / part types
|
||||||
|
2. Map a TecDoc `parts.name_part` value to (group, subgroup, part_type)
|
||||||
|
3. Translate any node name to Spanish using the existing translations.py
|
||||||
|
|
||||||
|
Business decisions (locked in by user 2026-04-08):
|
||||||
|
1. AMBIGUITY: first match wins (the order in nexpart_hierarchy.txt is
|
||||||
|
Nexpart's own canonical order, so the first match is also Nexpart's
|
||||||
|
primary classification).
|
||||||
|
2. UNMAPPED: drop. Parts without a clean Nexpart match do NOT appear in
|
||||||
|
Local mode. Local mode is intentionally smaller and more consistent.
|
||||||
|
3. LANGUAGE: bilingual via translations.py — single source of truth.
|
||||||
|
The hierarchy is stored in English; the UI translates each node
|
||||||
|
on-the-fly using `translate_taxonomy_node()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CONSTANTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
UNMAPPED_STRATEGY = "drop"
|
||||||
|
LANGUAGE_STRATEGY = "bilingual_taxonomy"
|
||||||
|
|
||||||
|
# Path to the source-of-truth hierarchy text file
|
||||||
|
_HIERARCHY_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "..", "CapturasWeb", "nexpart_hierarchy.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HIERARCHY PARSER
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# The list of valid groups, in canonical order (matches Nexpart's own order
|
||||||
|
# from the screenshots). Used to disambiguate "is this line a group header?"
|
||||||
|
# from "is this line a subgroup name?" — both can be capitalized.
|
||||||
|
_KNOWN_GROUPS = (
|
||||||
|
"IGNITION & FILTERS",
|
||||||
|
"BELTS, HOSES, WATER PUMPS & COOLING SYSTEM PARTS",
|
||||||
|
"STARTING & CHARGING SYSTEM PARTS (ALTERNATORS, BATTERIES & CABLES)",
|
||||||
|
"BRAKE SYSTEM, WHEEL BEARINGS, STUDS, NUTS & HARDWARE",
|
||||||
|
"FUEL & EMISSIONS PARTS",
|
||||||
|
"HEATING & AIR CONDITIONING",
|
||||||
|
"ENGINE PARTS",
|
||||||
|
"DRIVETRAIN PARTS",
|
||||||
|
"STEERING & SUSPENSION PARTS",
|
||||||
|
"EXHAUST, CLUTCH & FLYWHEEL PARTS",
|
||||||
|
"WIPERS, LAMPS & FUSES",
|
||||||
|
"BODY PARTS, CABLES, CAPS, ELECTRICAL MOTORS, SWITCHES & OTHER MISCELLANEOUS PARTS",
|
||||||
|
"CHEMICALS, WAXES & LUBRICANTS",
|
||||||
|
"TIRES, WHEELS, TOOLS & ACCESSORY PARTS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hierarchy_file() -> dict:
|
||||||
|
"""Parse nexpart_hierarchy.txt into a nested dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"Ignition & Filters": {
|
||||||
|
"Computers & Relays": ["Engine Control Module (ECM)", ...],
|
||||||
|
...
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
taxonomy = {}
|
||||||
|
current_group = None
|
||||||
|
current_subgroup = None
|
||||||
|
|
||||||
|
if not os.path.exists(_HIERARCHY_PATH):
|
||||||
|
return taxonomy
|
||||||
|
|
||||||
|
with open(_HIERARCHY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
|
||||||
|
# Skip comments, blank lines, and decoration rules
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if set(line.strip()) <= {"═", " "}:
|
||||||
|
continue
|
||||||
|
if line.strip() == "SUMMARY":
|
||||||
|
break # End-of-file marker
|
||||||
|
|
||||||
|
# Group header: ALL CAPS line that matches a known group
|
||||||
|
if line.strip().upper() in _KNOWN_GROUPS:
|
||||||
|
# Convert to title case for display, preserving the original
|
||||||
|
# casing from the .txt file (which already mixes Title Case)
|
||||||
|
current_group = line.strip().title() \
|
||||||
|
.replace("Ac ", "AC ") \
|
||||||
|
.replace("Pcv", "PCV") \
|
||||||
|
.replace("Ecm", "ECM") \
|
||||||
|
.replace("Cv ", "CV ") \
|
||||||
|
.replace("Vvt", "VVT") \
|
||||||
|
.replace("Tpms", "TPMS") \
|
||||||
|
.replace("Hvac", "HVAC") \
|
||||||
|
.replace("Abs ", "ABS ") \
|
||||||
|
.replace("Egr", "EGR")
|
||||||
|
taxonomy.setdefault(current_group, {})
|
||||||
|
current_subgroup = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Part type: lines with leading " - "
|
||||||
|
if line.lstrip().startswith("- "):
|
||||||
|
if current_group and current_subgroup:
|
||||||
|
pt = line.lstrip()[2:].strip()
|
||||||
|
taxonomy[current_group][current_subgroup].append(pt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Subgroup: a non-empty line that's not a comment, not a header,
|
||||||
|
# not a part type, and starts with a non-space character.
|
||||||
|
if line[0] not in (" ", "\t"):
|
||||||
|
current_subgroup = line.strip()
|
||||||
|
if current_group:
|
||||||
|
taxonomy[current_group].setdefault(current_subgroup, [])
|
||||||
|
|
||||||
|
return taxonomy
|
||||||
|
|
||||||
|
|
||||||
|
# Load at import time
|
||||||
|
NEXPART_TAXONOMY = _parse_hierarchy_file()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FLAT INDEX FOR FAST LOOKUP
|
||||||
|
# ============================================================================
|
||||||
|
# Building these once at import time means O(1) lookups during requests.
|
||||||
|
|
||||||
|
def _build_indexes():
|
||||||
|
"""Build flat lookup tables from the nested taxonomy."""
|
||||||
|
# part_type_lower → list of (group, subgroup, original_part_type)
|
||||||
|
# We use lowercase keys so the matcher is case-insensitive.
|
||||||
|
part_type_index = {}
|
||||||
|
all_part_types = [] # ordered list, in canonical Nexpart order
|
||||||
|
|
||||||
|
for group, subgroups in NEXPART_TAXONOMY.items():
|
||||||
|
for subgroup, part_types in subgroups.items():
|
||||||
|
for pt in part_types:
|
||||||
|
key = pt.strip().lower()
|
||||||
|
part_type_index.setdefault(key, []).append((group, subgroup, pt))
|
||||||
|
all_part_types.append((group, subgroup, pt))
|
||||||
|
return part_type_index, all_part_types
|
||||||
|
|
||||||
|
|
||||||
|
_PART_TYPE_INDEX, _ALL_PART_TYPES = _build_indexes()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DECISION 1 — RESOLVE AMBIGUITY (first-match wins)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Manual overrides for ambiguous part names. Key = lowercase TecDoc name
|
||||||
|
# (as fed to the matcher). Value = the subgroup WHERE the part should
|
||||||
|
# canonically live when a mechanic thinks about it.
|
||||||
|
#
|
||||||
|
# These beat the first-match rule. Add entries when you see that your users
|
||||||
|
# expect a part in a different subgroup than the one Nexpart's canonical
|
||||||
|
# order picks. Leave empty at start — grow incrementally from feedback.
|
||||||
|
#
|
||||||
|
# Example: a Mexican mechanic troubleshooting a failed emissions test will
|
||||||
|
# look for an O2 sensor under "Catalytic Converter" (system-level thinking),
|
||||||
|
# not "Emission Sensors, Relays, Solenoids & Switches" (component-level).
|
||||||
|
AMBIGUITY_OVERRIDES = {
|
||||||
|
# tecdoc name (lowercase) -> preferred subgroup name (exact string)
|
||||||
|
# (populated as real usage surfaces mismatches)
|
||||||
|
# 'oxygen sensor': 'Catalytic Converter',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ambiguous_subgroup(tecdoc_name: str, candidates: list) -> tuple:
|
||||||
|
"""Pick the canonical (group, subgroup, part_type) for an ambiguous name.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. AMBIGUITY_OVERRIDES dict — manual curation wins over everything.
|
||||||
|
2. First-match in canonical Nexpart order (Decision 1 locked in).
|
||||||
|
|
||||||
|
Search by the user still finds the part from anywhere via the flat
|
||||||
|
index; the override only affects which subgroup the part "lives in"
|
||||||
|
during hierarchical navigation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tecdoc_name: e.g. "Oxygen Sensor"
|
||||||
|
candidates: list of (group, subgroup, part_type) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single (group, subgroup, part_type) tuple.
|
||||||
|
"""
|
||||||
|
# 1. Manual override wins
|
||||||
|
key = (tecdoc_name or '').strip().lower()
|
||||||
|
preferred_subgroup = AMBIGUITY_OVERRIDES.get(key)
|
||||||
|
if preferred_subgroup:
|
||||||
|
for cand in candidates:
|
||||||
|
if cand[1] == preferred_subgroup:
|
||||||
|
return cand
|
||||||
|
# Override pointed to a subgroup not in the candidate set —
|
||||||
|
# log and fall through to first-match.
|
||||||
|
# (Using print to stay import-free; swap for logger if available.)
|
||||||
|
print(f"[taxonomy] AMBIGUITY_OVERRIDES['{key}'] = '{preferred_subgroup}' "
|
||||||
|
f"not in candidates {[c[1] for c in candidates]}; falling back")
|
||||||
|
|
||||||
|
# 2. First-match in canonical order
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DECISION 2 — UNMAPPED HANDLING (drop)
|
||||||
|
# ============================================================================
|
||||||
|
# When a TecDoc name doesn't match any Nexpart Part Type, the matcher
|
||||||
|
# returns None and the caller filters it out of Local mode results.
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CORE MATCHER: tecdoc_to_nexpart()
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def tecdoc_to_nexpart(tecdoc_name: str) -> Optional[tuple]:
|
||||||
|
"""Map a TecDoc part name to its Nexpart (group, subgroup, part_type).
|
||||||
|
|
||||||
|
Matching strategy (in order of preference):
|
||||||
|
1. Exact match (case-insensitive) on the full Part Type name.
|
||||||
|
2. Substring match — TecDoc name CONTAINS a known Part Type.
|
||||||
|
Example: "Front Brake Pad Set" contains "Brake Pad Set" → match.
|
||||||
|
3. Reverse substring — known Part Type contains the TecDoc name.
|
||||||
|
Example: TecDoc "Wiper" matches Nexpart "Wiper Arm". Less precise,
|
||||||
|
used as last resort.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tecdoc_name: value from `parts.name_part` (English)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(group, subgroup, part_type) if matched, None otherwise.
|
||||||
|
Per Decision 2, callers should filter out None values.
|
||||||
|
"""
|
||||||
|
if not tecdoc_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name_lower = tecdoc_name.strip().lower()
|
||||||
|
if not name_lower:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 1. Exact match
|
||||||
|
if name_lower in _PART_TYPE_INDEX:
|
||||||
|
candidates = _PART_TYPE_INDEX[name_lower]
|
||||||
|
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
|
||||||
|
|
||||||
|
# 2. Substring match (TecDoc contains Nexpart Part Type)
|
||||||
|
# Prefer the LONGEST match — more specific wins on a tie of position.
|
||||||
|
best_match = None
|
||||||
|
best_len = 0
|
||||||
|
for pt_key, candidates in _PART_TYPE_INDEX.items():
|
||||||
|
if pt_key in name_lower and len(pt_key) > best_len:
|
||||||
|
best_match = candidates
|
||||||
|
best_len = len(pt_key)
|
||||||
|
if best_match:
|
||||||
|
return resolve_ambiguous_subgroup(tecdoc_name, best_match)
|
||||||
|
|
||||||
|
# 3. Reverse substring (Nexpart Part Type contains TecDoc) — last resort
|
||||||
|
for pt_key, candidates in _PART_TYPE_INDEX.items():
|
||||||
|
if name_lower in pt_key and len(name_lower) >= 4:
|
||||||
|
# Min length 4 to avoid false matches on short words like "Cap"
|
||||||
|
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DECISION 3 — BILINGUAL VIA translations.py
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Curated translations for the 14 top-level groups + common subgroups.
|
||||||
|
# These are full-string (not substring) so they always win over the partial
|
||||||
|
# matcher in translations.py and produce clean Spanish display.
|
||||||
|
TAXONOMY_OVERRIDES_ES = {
|
||||||
|
# ─── Top-level groups (14) ───
|
||||||
|
"Ignition & Filters": "Encendido y Filtros",
|
||||||
|
"Belts, Hoses, Water Pumps & Cooling System Parts": "Bandas, Mangueras, Bombas de Agua y Sistema de Enfriamiento",
|
||||||
|
"Starting & Charging System Parts (Alternators, Batteries & Cables)": "Sistema de Arranque y Carga (Alternadores, Baterías y Cables)",
|
||||||
|
"Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Sistema de Frenos, Baleros, Birlos, Tuercas y Ferretería",
|
||||||
|
"Fuel & Emissions Parts": "Combustible y Emisiones",
|
||||||
|
"Heating & Air Conditioning": "Calefacción y Aire Acondicionado",
|
||||||
|
"Engine Parts": "Partes de Motor",
|
||||||
|
"Drivetrain Parts": "Tren Motriz",
|
||||||
|
"Steering & Suspension Parts": "Dirección y Suspensión",
|
||||||
|
"Exhaust, Clutch & Flywheel Parts": "Escape, Clutch y Volante",
|
||||||
|
"Wipers, Lamps & Fuses": "Limpiaparabrisas, Luces y Fusibles",
|
||||||
|
"Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería, Cables, Tapones, Motores Eléctricos, Switches y Misceláneos",
|
||||||
|
"Chemicals, Waxes & Lubricants": "Químicos, Ceras y Lubricantes",
|
||||||
|
"Tires, Wheels, Tools & Accessory Parts": "Llantas, Rines, Herramientas y Accesorios",
|
||||||
|
|
||||||
|
# ─── Common subgroups (the most-used ones; expand as needed) ───
|
||||||
|
"Filters & PCV": "Filtros y PCV",
|
||||||
|
"Spark Plugs & Glow Plugs": "Bujías",
|
||||||
|
"Tune-Up & Ignition Parts": "Afinación y Encendido",
|
||||||
|
"Belts, Tensioners & Pulleys": "Bandas, Tensores y Poleas",
|
||||||
|
"Radiators & Electric Fan Motors": "Radiadores y Motoventiladores",
|
||||||
|
"Thermostats, Housings & Radiator Caps": "Termostatos, Carcasas y Tapones de Radiador",
|
||||||
|
"Water Pumps, Fan Blades & Clutches": "Bombas de Agua, Aspas y Fan Clutches",
|
||||||
|
"Alternators & Voltage Regulators": "Alternadores y Reguladores de Voltaje",
|
||||||
|
"Batteries": "Baterías",
|
||||||
|
"Starters": "Marchas / Arrancadores",
|
||||||
|
"ABS Controls & Parts": "Controles y Partes de ABS",
|
||||||
|
"Front Friction, Drums & Rotors": "Frenos Delanteros: Pastillas, Tambores y Discos",
|
||||||
|
"Rear Friction, Drums & Rotors": "Frenos Traseros: Pastillas, Tambores y Discos",
|
||||||
|
"Front Wheel Bearings & Seals": "Baleros y Sellos de Rueda Delantera",
|
||||||
|
"Rear Wheel Bearings & Seals": "Baleros y Sellos de Rueda Trasera",
|
||||||
|
"Master Cylinders, Boosters & Switches": "Cilindros Maestros, Boosters y Switches",
|
||||||
|
"Fuel Pumps & Tanks": "Bombas y Tanques de Gasolina",
|
||||||
|
"Fuel Injection Parts, Mass Air Flow Sensors": "Inyección, Sensores MAF",
|
||||||
|
"Turbochargers & Superchargers": "Turbos y Compresores",
|
||||||
|
"AC Compressors, Kits & Parts": "Compresores de A/C y Kits",
|
||||||
|
"AC Condensers & Evaporators": "Condensadores y Evaporadores de A/C",
|
||||||
|
"Cams, Lifters & Timing Parts": "Árboles de Levas, Buzos y Distribución",
|
||||||
|
"Crankshafts & Bearings": "Cigüeñales y Metales",
|
||||||
|
"Pistons, Rings & Rods": "Pistones, Anillos y Bielas",
|
||||||
|
"Heads & Manifolds": "Cabezas y Múltiples",
|
||||||
|
"Engine Mounts & Other Miscellaneous Engine Parts": "Soportes de Motor y Otros",
|
||||||
|
"Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Flechas, Crucetas y Juntas Homocinéticas",
|
||||||
|
"Automatic Transmission Seals": "Sellos de Transmisión Automática",
|
||||||
|
"Manual Transmission Seals": "Sellos de Transmisión Manual",
|
||||||
|
"Transmission & Parts": "Transmisión y Partes",
|
||||||
|
"Ball Joints & Control Arms": "Rótulas y Horquillas",
|
||||||
|
"Shock Absorbers & Struts": "Amortiguadores y Strut",
|
||||||
|
"Steering Linkages, Rods & Arms": "Direcciones, Bieletas y Brazos",
|
||||||
|
"Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Barras Estabilizadoras y Tornillos",
|
||||||
|
"All Exhaust & Diagrams": "Sistema de Escape Completo",
|
||||||
|
"Catalytic Converter": "Convertidor Catalítico",
|
||||||
|
"Clutches & Clutch Kits": "Clutches y Kits",
|
||||||
|
"Manifolds & Headers": "Múltiples y Headers",
|
||||||
|
"Arms, Blades & Refills": "Brazos, Plumas y Repuestos",
|
||||||
|
"Headlamps & Flashers": "Faros y Direccionales",
|
||||||
|
"Exterior Lamps": "Luces Exteriores",
|
||||||
|
"Interior Lamps": "Luces Interiores",
|
||||||
|
"Wiper Motors & Washer Pumps": "Motores de Limpia y Bombas de Agua",
|
||||||
|
"Bumpers & License Plates": "Defensas y Placas",
|
||||||
|
"Door, Window & Tailgate Parts": "Puertas, Ventanas y Cajuela",
|
||||||
|
"Engine & Transmission Lubricants & Additives": "Aceites de Motor y Transmisión",
|
||||||
|
"Tires & Wheels": "Llantas y Rines",
|
||||||
|
"Tools, Jacks, Hardware & Manuals": "Herramientas, Gatos y Hardware",
|
||||||
|
|
||||||
|
# ─── Remaining subgroups (phase 2 translation coverage) ───
|
||||||
|
"Computers & Relays": "Computadoras y Relés",
|
||||||
|
"Ignition Wires": "Cables de Bujía",
|
||||||
|
"Miscellaneous Ignition Parts": "Conectores y Misceláneos de Encendido",
|
||||||
|
"Engine Coolant & Bypass Hoses": "Mangueras de Refrigerante y Bypass",
|
||||||
|
"Heater & Other Hoses": "Mangueras de Calefacción y Otras",
|
||||||
|
"Sensors, Switches & Relays": "Sensores, Switches y Relés",
|
||||||
|
"Starter Solenoids, Switches & Relays": "Solenoides de Marcha, Switches y Relés",
|
||||||
|
"Brake Cables, Studs, Nuts & Spindle Nuts": "Cables, Birlos y Tuercas de Freno",
|
||||||
|
"Front Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Delanteros",
|
||||||
|
"Front Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Delanteras",
|
||||||
|
"Miscellaneous Disc Hardware": "Ferretería Misceláneo de Disco",
|
||||||
|
"Miscellaneous Drum Hardware": "Ferretería Misceláneo de Tambor",
|
||||||
|
"Miscellaneous Hydraulic Parts & Brake Specifications": "Hidráulica y Especificaciones de Freno",
|
||||||
|
"Rear Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Traseros",
|
||||||
|
"Rear Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Traseras",
|
||||||
|
"Carburetors, Carburetor Kits & Components": "Carburadores, Kits y Componentes",
|
||||||
|
"EGR & Emissions Valves": "EGR y Válvulas de Emisiones",
|
||||||
|
"Emission Sensors, Relays, Solenoids & Switches": "Sensores de Emisiones, Relés, Solenoides y Switches",
|
||||||
|
"Fuel Injection Harnesses, Connectors & Miscellaneous Parts": "Arneses, Conectores e Inyección Misceláneos",
|
||||||
|
"Fuel Injection Sensors, Relays & Switches": "Sensores, Relés y Switches de Inyección",
|
||||||
|
"AC Accumulators, Receiver Driers & Valves": "Acumuladores, Secadores y Válvulas de A/C",
|
||||||
|
"AC Hose Assemblies & Fittings": "Mangueras y Conexiones de A/C",
|
||||||
|
"AC Relays & Switches": "Relés y Switches de A/C",
|
||||||
|
"AC, Heating & Ventilation Gaskets, O-Rings, Kits, Doors & Actuators": "Juntas, O-Rings, Puertas y Actuadores A/C",
|
||||||
|
"Blower Motors & Parts": "Motores de Ventilador y Partes",
|
||||||
|
"Heater Cores & Heater Control Valves": "Radiadores de Calefacción y Válvulas",
|
||||||
|
"Engine Block Parts": "Partes de Bloque de Motor",
|
||||||
|
"Engines & Kits": "Motores y Kits",
|
||||||
|
"Gasket Sets": "Juegos de Juntas",
|
||||||
|
"Individual Gaskets & Seals": "Juntas y Sellos Individuales",
|
||||||
|
"Intake & Exhaust Valves": "Válvulas de Admisión y Escape",
|
||||||
|
"Rockers & Push Rods": "Balancines y Varillas de Empuje",
|
||||||
|
"Vacuum & Oil Pumps": "Bombas de Vacío y Aceite",
|
||||||
|
"Axle & Differential Parts": "Partes de Eje y Diferencial",
|
||||||
|
"Electronics, Sensors, Relays & Miscellaneous Parts": "Electrónica, Sensores y Misceláneos",
|
||||||
|
"Manual Transmission Bearings": "Baleros de Transmisión Manual",
|
||||||
|
"Spindles & Hubs": "Husillos y Mazas",
|
||||||
|
"Transmission Kits & Gaskets": "Kits y Juntas de Transmisión",
|
||||||
|
"Alignment Kits & Tools": "Kits y Herramientas de Alineación",
|
||||||
|
"King Pins, Trailing Arms, Alignment & Other Chassis": "Pivotes, Brazos y Otros de Chasis",
|
||||||
|
"Power Steering Pumps, Hoses & Kits": "Bombas, Mangueras y Kits de Dirección Hidráulica",
|
||||||
|
"Rack & Pinion, Gear Box, Power Cylinder": "Cremallera, Caja de Dirección y Cilindro",
|
||||||
|
"Clutch Hydraulics": "Hidráulica de Clutch",
|
||||||
|
"Individual Exhaust Parts": "Partes de Escape Individuales",
|
||||||
|
"Miscellaneous Clutch Parts": "Partes Misceláneas de Clutch",
|
||||||
|
"Lighting Modules & Switches": "Módulos y Switches de Iluminación",
|
||||||
|
"Lighting Relays & Sensors": "Relés y Sensores de Luces",
|
||||||
|
"Caps": "Tapones",
|
||||||
|
"Cruise Control Parts": "Partes de Control de Crucero",
|
||||||
|
"Electrical Motors": "Motores Eléctricos",
|
||||||
|
"Glass": "Cristales",
|
||||||
|
"Hood & Tailgate Parts": "Partes de Cofre y Cajuela",
|
||||||
|
"Hoods Fenders & Body Parts": "Cofres, Salpicaderas y Carrocería",
|
||||||
|
"Lift Supports": "Amortiguadores de Cofre/Cajuela",
|
||||||
|
"Switches, Relays & Miscellaneous Parts": "Switches, Relés y Misceláneos",
|
||||||
|
"Wheel & Hardware": "Rines y Ferretería",
|
||||||
|
"Bumper & License Plate": "Defensas y Placas",
|
||||||
|
"Electronics Audio/Visual & Mirrors": "Electrónica, Audio y Espejos",
|
||||||
|
"Hood, Fender & Body Parts": "Cofre, Salpicaderas y Carrocería",
|
||||||
|
"Interior & Steering Wheel": "Interior y Volante",
|
||||||
|
|
||||||
|
# ─── High-value part types (most-searched in real use) ───
|
||||||
|
# Ignition & Filters
|
||||||
|
"Engine Control Module (ECM)": "Módulo de Control del Motor (ECM)",
|
||||||
|
"Ignition Relay": "Relé de Encendido",
|
||||||
|
"Transmission Control Module": "Módulo de Control de Transmisión",
|
||||||
|
"Engine Air Filter": "Filtro de Aire del Motor",
|
||||||
|
"Engine Oil Filter": "Filtro de Aceite del Motor",
|
||||||
|
"Engine Oil Filter Adapter": "Adaptador de Filtro de Aceite",
|
||||||
|
"Engine Oil Filter Housing": "Carcasa de Filtro de Aceite",
|
||||||
|
"Vapor Canister": "Canister de Vapor",
|
||||||
|
"Vapor Canister Purge Valve": "Válvula de Purga del Canister",
|
||||||
|
"Vapor Canister Purge Solenoid": "Solenoide de Purga del Canister",
|
||||||
|
"Spark Plug Set": "Juego de Bujías",
|
||||||
|
"Direct Ignition Coil": "Bobina de Encendido Directo",
|
||||||
|
"Ignition Coil": "Bobina de Encendido",
|
||||||
|
"Ignition Kit": "Kit de Encendido",
|
||||||
|
|
||||||
|
# Belts / Cooling
|
||||||
|
"Engine Timing Belt": "Banda de Distribución",
|
||||||
|
"Engine Timing Belt Component Kit": "Kit de Componentes de Distribución",
|
||||||
|
"Engine Timing Belt Kit with Water Pump": "Kit de Distribución con Bomba de Agua",
|
||||||
|
"Engine Timing Chain": "Cadena de Distribución",
|
||||||
|
"Engine Timing Chain Guide": "Guía de Cadena de Distribución",
|
||||||
|
"Engine Timing Chain Tensioner": "Tensor de Cadena de Distribución",
|
||||||
|
"Accessory Drive Belt Tensioner Assembly": "Tensor de Banda Accesoria",
|
||||||
|
"Accessory Drive Belt Tensioner Pulley": "Polea Tensora de Banda Accesoria",
|
||||||
|
"Serpentine Belt": "Banda Serpentina",
|
||||||
|
"Radiator": "Radiador",
|
||||||
|
"Radiator Coolant Hose": "Manguera de Refrigerante del Radiador",
|
||||||
|
"Engine Coolant Reservoir": "Depósito de Refrigerante",
|
||||||
|
"Engine Water Pump": "Bomba de Agua del Motor",
|
||||||
|
"Engine Water Pump Gasket": "Junta de Bomba de Agua",
|
||||||
|
"Engine Water Pump Pulley": "Polea de Bomba de Agua",
|
||||||
|
"Engine Coolant Thermostat": "Termostato de Refrigerante",
|
||||||
|
"Engine Coolant Thermostat Housing": "Carcasa de Termostato",
|
||||||
|
"Engine Coolant Temperature Sensor": "Sensor de Temperatura de Refrigerante",
|
||||||
|
"Engine Cooling Fan": "Ventilador de Enfriamiento",
|
||||||
|
"Engine Cooling Fan Assembly": "Conjunto de Ventilador de Enfriamiento",
|
||||||
|
"HVAC Heater Hose": "Manguera de Calefacción HVAC",
|
||||||
|
|
||||||
|
# Starting & Charging
|
||||||
|
"Alternator": "Alternador",
|
||||||
|
"Vehicle Battery": "Batería del Vehículo",
|
||||||
|
"Starter": "Marcha / Arrancador",
|
||||||
|
"Ignition Lock Cylinder": "Switch de Encendido (Cilindro)",
|
||||||
|
"Ignition Switch": "Switch de Encendido",
|
||||||
|
|
||||||
|
# Brake System
|
||||||
|
"ABS Wheel Speed Sensor": "Sensor de Velocidad de Rueda ABS",
|
||||||
|
"Front Disc Brake Pad Set": "Juego de Pastillas Delanteras",
|
||||||
|
"Rear Disc Brake Pad Set": "Juego de Pastillas Traseras",
|
||||||
|
"Front Disc Brake Rotor": "Disco de Freno Delantero",
|
||||||
|
"Rear Disc Brake Rotor": "Disco de Freno Trasero",
|
||||||
|
"Front Disc Brake Caliper": "Caliper de Freno Delantero",
|
||||||
|
"Rear Disc Brake Caliper": "Caliper de Freno Trasero",
|
||||||
|
"Front Brake Hydraulic Hose": "Manguera Hidráulica Delantera",
|
||||||
|
"Rear Brake Hydraulic Hose": "Manguera Hidráulica Trasera",
|
||||||
|
"Brake Master Cylinder": "Cilindro Maestro de Frenos",
|
||||||
|
"Power Brake Booster": "Booster de Frenos",
|
||||||
|
"Front Wheel Bearing": "Balero de Rueda Delantera",
|
||||||
|
"Rear Wheel Bearing": "Balero de Rueda Trasera",
|
||||||
|
"Front Wheel Bearing and Hub Assembly": "Balero y Maza Delantera",
|
||||||
|
"Rear Wheel Bearing and Hub Assembly": "Balero y Maza Trasera",
|
||||||
|
"Wheel Lug Nut": "Tuerca de Rueda (Birlo)",
|
||||||
|
"Wheel Lug Stud": "Birlo de Rueda",
|
||||||
|
|
||||||
|
# Fuel & Emissions
|
||||||
|
"Electric Fuel Pump": "Bomba Eléctrica de Gasolina",
|
||||||
|
"Fuel Pump Module Assembly": "Conjunto de Módulo de Bomba de Gasolina",
|
||||||
|
"Fuel Level Sensor": "Sensor de Nivel de Gasolina",
|
||||||
|
"Fuel Tank Cap": "Tapón de Tanque de Gasolina",
|
||||||
|
"Fuel Injector": "Inyector de Gasolina",
|
||||||
|
"Fuel Injector Set": "Juego de Inyectores",
|
||||||
|
"Fuel Injection Throttle Body": "Cuerpo de Aceleración",
|
||||||
|
"Mass Air Flow Sensor": "Sensor MAF (Flujo de Aire)",
|
||||||
|
"Oxygen Sensor": "Sensor de Oxígeno",
|
||||||
|
"Engine Camshaft Position Sensor": "Sensor de Posición de Árbol de Levas",
|
||||||
|
"Engine Crankshaft Position Sensor": "Sensor de Posición del Cigüeñal",
|
||||||
|
"Engine Knock Sensor": "Sensor de Detonación",
|
||||||
|
"Manifold Absolute Pressure Sensor": "Sensor MAP (Presión Absoluta)",
|
||||||
|
"Turbocharger": "Turbocargador",
|
||||||
|
|
||||||
|
# Heating & AC
|
||||||
|
"A/C Compressor": "Compresor de A/C",
|
||||||
|
"A/C Condenser": "Condensador de A/C",
|
||||||
|
"A/C Evaporator Core": "Evaporador de A/C",
|
||||||
|
"A/C Expansion Valve": "Válvula de Expansión de A/C",
|
||||||
|
"A/C Receiver Drier/Desiccant Element": "Filtro Deshidratador de A/C",
|
||||||
|
"A/C Hose Assembly": "Manguera de A/C",
|
||||||
|
"HVAC Blower Motor": "Motor de Ventilador HVAC",
|
||||||
|
"HVAC Blower Motor Resistor": "Resistencia de Ventilador HVAC",
|
||||||
|
"HVAC Heater Core": "Radiador de Calefacción",
|
||||||
|
"HVAC Blend Door Actuator": "Actuador de Puerta de Mezcla",
|
||||||
|
|
||||||
|
# Engine Parts
|
||||||
|
"Engine Camshaft": "Árbol de Levas",
|
||||||
|
"Engine Harmonic Balancer": "Damper / Polea del Cigüeñal",
|
||||||
|
"Engine Crankshaft Main Bearing Set": "Juego de Metales de Bancada",
|
||||||
|
"Engine Piston": "Pistón",
|
||||||
|
"Engine Piston Ring Set": "Juego de Anillos de Pistón",
|
||||||
|
"Engine Connecting Rod Bearing Set": "Juego de Metales de Biela",
|
||||||
|
"Engine Cylinder Head Gasket": "Junta de Cabeza de Cilindros",
|
||||||
|
"Engine Cylinder Head Bolt Set": "Juego de Tornillos de Cabeza",
|
||||||
|
"Engine Intake Manifold": "Múltiple de Admisión",
|
||||||
|
"Engine Intake Manifold Gasket": "Junta de Múltiple de Admisión",
|
||||||
|
"Engine Valve Cover": "Tapa de Válvulas",
|
||||||
|
"Engine Valve Cover Gasket": "Junta de Tapa de Válvulas",
|
||||||
|
"Engine Oil Pan": "Cárter de Aceite",
|
||||||
|
"Engine Oil Pan Gasket": "Junta de Cárter",
|
||||||
|
"Engine Oil Pump": "Bomba de Aceite",
|
||||||
|
"Engine Oil Pressure Sender": "Sensor de Presión de Aceite",
|
||||||
|
"Engine Oil Pressure Switch": "Switch de Presión de Aceite",
|
||||||
|
"Engine Mount": "Soporte de Motor",
|
||||||
|
"Engine Rocker Arm": "Balancín",
|
||||||
|
"Engine Exhaust Valve": "Válvula de Escape",
|
||||||
|
"Engine Intake Valve": "Válvula de Admisión",
|
||||||
|
"Engine Valve Spring": "Resorte de Válvula",
|
||||||
|
"Engine Valve Stem Oil Seal": "Sello de Válvula",
|
||||||
|
|
||||||
|
# Drivetrain
|
||||||
|
"CV Axle Assembly": "Flecha Homocinética Completa",
|
||||||
|
"CV Axle Shaft": "Flecha Homocinética",
|
||||||
|
"Automatic Transmission Mount": "Soporte de Transmisión Automática",
|
||||||
|
"Automatic Transmission Oil Cooler": "Enfriador de Aceite de Transmisión",
|
||||||
|
"Automatic Transmission Oil Pan": "Cárter de Transmisión Automática",
|
||||||
|
"Manual Transmission Mount": "Soporte de Transmisión Manual",
|
||||||
|
"Transmission Filter Kit": "Kit de Filtro de Transmisión",
|
||||||
|
"Transmission Oil Pan": "Cárter de Transmisión",
|
||||||
|
"Spindle Nut": "Tuerca de Husillo",
|
||||||
|
"Vehicle Speed Sensor": "Sensor de Velocidad del Vehículo",
|
||||||
|
|
||||||
|
# Steering & Suspension
|
||||||
|
"Suspension Ball Joint": "Rótula de Suspensión",
|
||||||
|
"Suspension Control Arm Bushing": "Buje de Horquilla",
|
||||||
|
"Suspension Control Arm and Ball Joint Assembly": "Horquilla con Rótula",
|
||||||
|
"Suspension Shock Absorber": "Amortiguador",
|
||||||
|
"Suspension Strut": "Strut de Suspensión",
|
||||||
|
"Suspension Strut Assembly": "Conjunto de Strut",
|
||||||
|
"Suspension Strut Mount": "Base de Strut",
|
||||||
|
"Suspension Stabilizer Bar Link": "Terminal de Barra Estabilizadora",
|
||||||
|
"Steering Tie Rod End": "Terminal de Dirección",
|
||||||
|
"Rack and Pinion Assembly": "Cremallera de Dirección",
|
||||||
|
"Steering Column": "Columna de Dirección",
|
||||||
|
|
||||||
|
# Exhaust/Clutch
|
||||||
|
"Catalytic Converter": "Convertidor Catalítico",
|
||||||
|
"Catalytic Converter Gasket": "Junta de Convertidor Catalítico",
|
||||||
|
"Exhaust Manifold": "Múltiple de Escape",
|
||||||
|
"Exhaust Manifold Gasket": "Junta de Múltiple de Escape",
|
||||||
|
"Exhaust Muffler": "Mofle",
|
||||||
|
"Exhaust Muffler Assembly": "Conjunto de Mofle",
|
||||||
|
"Exhaust Pipe": "Tubo de Escape",
|
||||||
|
"Exhaust Clamp": "Abrazadera de Escape",
|
||||||
|
"Clutch Slave Cylinder": "Cilindro Esclavo de Clutch",
|
||||||
|
"Transmission Clutch Kit": "Kit de Clutch",
|
||||||
|
|
||||||
|
# Wipers/Lamps
|
||||||
|
"Wiper Arm": "Brazo de Limpiaparabrisas",
|
||||||
|
"Wiper Blade": "Pluma Limpiaparabrisas",
|
||||||
|
"Wiper Motor": "Motor de Limpiaparabrisas",
|
||||||
|
"Wiper Switch": "Switch de Limpiaparabrisas",
|
||||||
|
"Headlight Bulb": "Foco de Faro",
|
||||||
|
"Tail Light Bulb": "Foco de Calavera",
|
||||||
|
"Brake Light Bulb": "Foco de Freno",
|
||||||
|
"Turn Signal Light Bulb": "Foco Direccional",
|
||||||
|
"Fog Light Bulb": "Foco Antiniebla",
|
||||||
|
"Back Up Light Bulb": "Foco de Reversa",
|
||||||
|
"License Plate Light Bulb": "Foco de Placa",
|
||||||
|
"Dome Light Bulb": "Foco de Domo",
|
||||||
|
"Washer Fluid Reservoir Cap": "Tapón de Depósito de Limpiaparabrisas",
|
||||||
|
"Headlight Switch": "Switch de Luces",
|
||||||
|
"Turn Signal Switch": "Switch de Direccionales",
|
||||||
|
"Multi-Function Switch": "Switch Multifunciones",
|
||||||
|
"Hazard Warning Switch": "Switch de Intermitentes",
|
||||||
|
|
||||||
|
# Body / Electrical / Misc
|
||||||
|
"Door Lock Actuator": "Actuador de Cerradura",
|
||||||
|
"Door Lock Actuator Motor": "Motor de Actuador de Cerradura",
|
||||||
|
"Window Motor": "Motor de Ventana",
|
||||||
|
"Window Regulator": "Elevador de Ventana",
|
||||||
|
"Window Motor and Regulator Assembly": "Motor y Elevador de Ventana",
|
||||||
|
"Sunroof Motor": "Motor de Quemacocos",
|
||||||
|
"Exterior Door Handle": "Manija Exterior de Puerta",
|
||||||
|
"Interior Door Handle": "Manija Interior de Puerta",
|
||||||
|
"Door Mirror Glass": "Cristal de Espejo",
|
||||||
|
"Horn Relay": "Relé de Claxon",
|
||||||
|
"Liftgate Lift Support": "Amortiguador de Cajuela",
|
||||||
|
"Cruise Control Switch": "Switch de Control de Crucero",
|
||||||
|
"Engine Coolant Reservoir Cap": "Tapón de Depósito de Refrigerante",
|
||||||
|
"Engine Oil Filler Cap": "Tapón de Llenado de Aceite",
|
||||||
|
"Radiator Cap": "Tapón de Radiador",
|
||||||
|
"TPMS Sensor": "Sensor TPMS",
|
||||||
|
"TPMS Programmable Sensor": "Sensor TPMS Programable",
|
||||||
|
|
||||||
|
# Chemicals / Tools
|
||||||
|
"Automatic Transmission Fluid": "Aceite de Transmisión Automática",
|
||||||
|
"Engine Oil": "Aceite de Motor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_taxonomy_node(english_name: str) -> str:
|
||||||
|
"""Translate a Nexpart group / subgroup / part type to Spanish.
|
||||||
|
|
||||||
|
STRICT lookup only — no partial substitution. The order:
|
||||||
|
1. TAXONOMY_OVERRIDES_ES — full-string curated translations.
|
||||||
|
2. PART_TRANSLATIONS exact match (from services.translations).
|
||||||
|
3. Fallback: return the English original UNCHANGED.
|
||||||
|
|
||||||
|
Why strict-only: partial substitution within a compound name produces
|
||||||
|
ugly hybrids ("Front Tambor de Freno", "Engine Filtro de Aceite").
|
||||||
|
For taxonomy display we'd rather show clean English than dirty Spanish.
|
||||||
|
Untranslated entries are visible reminders to extend the override dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
english_name: the canonical English name (group, subgroup, or part type)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Spanish display string, or the English original if no exact match.
|
||||||
|
"""
|
||||||
|
if not english_name:
|
||||||
|
return english_name
|
||||||
|
|
||||||
|
# 1. Curated overrides (highest priority)
|
||||||
|
if english_name in TAXONOMY_OVERRIDES_ES:
|
||||||
|
return TAXONOMY_OVERRIDES_ES[english_name]
|
||||||
|
|
||||||
|
# 2. Exact match in PART_TRANSLATIONS
|
||||||
|
try:
|
||||||
|
from services.translations import PART_TRANSLATIONS
|
||||||
|
if english_name in PART_TRANSLATIONS:
|
||||||
|
return PART_TRANSLATIONS[english_name]
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Fallback — return English unchanged
|
||||||
|
return english_name
|
||||||
|
|
||||||
|
|
||||||
|
def list_untranslated_nodes() -> dict:
|
||||||
|
"""Diagnostic helper: list every taxonomy node missing a Spanish entry.
|
||||||
|
|
||||||
|
Useful for filling in TAXONOMY_OVERRIDES_ES incrementally — run this
|
||||||
|
in a one-off script to see exactly what still needs translation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"groups": [...], "subgroups": [...], "part_types": [...]}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from services.translations import PART_TRANSLATIONS
|
||||||
|
known = set(PART_TRANSLATIONS.keys()) | set(TAXONOMY_OVERRIDES_ES.keys())
|
||||||
|
except ImportError:
|
||||||
|
known = set(TAXONOMY_OVERRIDES_ES.keys())
|
||||||
|
|
||||||
|
missing = {"groups": [], "subgroups": [], "part_types": []}
|
||||||
|
for group, subgroups in NEXPART_TAXONOMY.items():
|
||||||
|
if group not in known:
|
||||||
|
missing["groups"].append(group)
|
||||||
|
for subgroup, part_types in subgroups.items():
|
||||||
|
if subgroup not in known:
|
||||||
|
missing["subgroups"].append(subgroup)
|
||||||
|
for pt in part_types:
|
||||||
|
if pt not in known:
|
||||||
|
missing["part_types"].append(pt)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUBLIC API — used by catalog_service / blueprints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def get_groups() -> list:
|
||||||
|
"""Return the 14 top-level groups in canonical order.
|
||||||
|
|
||||||
|
Each item: {"name": english, "name_es": spanish, "subgroup_count": int}
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": group,
|
||||||
|
"name_es": translate_taxonomy_node(group),
|
||||||
|
"subgroup_count": len(subgroups),
|
||||||
|
}
|
||||||
|
for group, subgroups in NEXPART_TAXONOMY.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_subgroups(group_name: str) -> list:
|
||||||
|
"""Return all subgroups for a given group.
|
||||||
|
|
||||||
|
Each item: {"name": english, "name_es": spanish, "part_type_count": int}
|
||||||
|
"""
|
||||||
|
subgroups = NEXPART_TAXONOMY.get(group_name, {})
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": subgroup,
|
||||||
|
"name_es": translate_taxonomy_node(subgroup),
|
||||||
|
"part_type_count": len(part_types),
|
||||||
|
}
|
||||||
|
for subgroup, part_types in subgroups.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_part_types(group_name: str, subgroup_name: str) -> list:
|
||||||
|
"""Return all part types within a group + subgroup.
|
||||||
|
|
||||||
|
Each item: {"name": english, "name_es": spanish}
|
||||||
|
"""
|
||||||
|
subgroups = NEXPART_TAXONOMY.get(group_name, {})
|
||||||
|
part_types = subgroups.get(subgroup_name, [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": pt,
|
||||||
|
"name_es": translate_taxonomy_node(pt),
|
||||||
|
}
|
||||||
|
for pt in part_types
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def stats() -> dict:
|
||||||
|
"""Return totals — useful for healthcheck and debugging."""
|
||||||
|
total_subgroups = sum(len(sg) for sg in NEXPART_TAXONOMY.values())
|
||||||
|
total_part_types = sum(
|
||||||
|
len(pts)
|
||||||
|
for sg in NEXPART_TAXONOMY.values()
|
||||||
|
for pts in sg.values()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"groups": len(NEXPART_TAXONOMY),
|
||||||
|
"subgroups": total_subgroups,
|
||||||
|
"part_types": total_part_types,
|
||||||
|
"indexed_keys": len(_PART_TYPE_INDEX),
|
||||||
|
}
|
||||||
240
pos/services/peer_service.py
Normal file
240
pos/services/peer_service.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Peer-to-peer inventory service for multi-instance Nexus deployments.
|
||||||
|
|
||||||
|
Each Nexus instance is autonomous (own DB, own POS) but can see inventory
|
||||||
|
from other instances on the network. The marketplace fans out to all peers
|
||||||
|
and merges results so users see stock from the whole Nexus network.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- peers.json: config file listing known peer instances (name + URL)
|
||||||
|
- /pos/api/peer/inventory: public endpoint each instance exposes (no auth)
|
||||||
|
- search_all_peers(): fan-out query to all enabled peers + local DB
|
||||||
|
|
||||||
|
For the demo (LAN), peers are static IPs in peers.json.
|
||||||
|
For production (clients on own networks), this will evolve into a central
|
||||||
|
hub model where each instance reports to a cloud server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
|
||||||
|
_config_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config():
|
||||||
|
"""Load peers.json, cached in memory after first read."""
|
||||||
|
global _config_cache
|
||||||
|
if _config_cache is not None:
|
||||||
|
return _config_cache
|
||||||
|
try:
|
||||||
|
with open(_CONFIG_PATH, 'r') as f:
|
||||||
|
_config_cache = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
print(f'[peer] Warning: could not load {_CONFIG_PATH}: {e}')
|
||||||
|
_config_cache = {'instance_name': 'Unknown', 'peers': [], 'peer_timeout_seconds': 3}
|
||||||
|
return _config_cache
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config():
|
||||||
|
"""Force-reload peers.json (call after editing the file)."""
|
||||||
|
global _config_cache
|
||||||
|
_config_cache = None
|
||||||
|
return _load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance_name() -> str:
|
||||||
|
return _load_config().get('instance_name', 'Unknown')
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance_id() -> str:
|
||||||
|
return _load_config().get('instance_id', 'unknown')
|
||||||
|
|
||||||
|
|
||||||
|
def get_peers() -> list[dict]:
|
||||||
|
"""Return list of enabled peers: [{name, url, enabled}]"""
|
||||||
|
cfg = _load_config()
|
||||||
|
return [p for p in cfg.get('peers', []) if p.get('enabled', True)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeout() -> int:
|
||||||
|
return _load_config().get('peer_timeout_seconds', 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Local inventory query (what WE expose to peers) ─────────────────────
|
||||||
|
|
||||||
|
def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list[dict]:
|
||||||
|
"""Query this instance's inventory for the peer endpoint.
|
||||||
|
|
||||||
|
Returns parts WITH stock > 0, with enough detail for the marketplace
|
||||||
|
to render results (part number, name, brand, price, stock hint).
|
||||||
|
No exact stock numbers — just 'En stock' (per business decision).
|
||||||
|
"""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
|
||||||
|
# Build WHERE clause
|
||||||
|
clauses = ["COALESCE(s.stock, 0) > 0", "i.is_active = TRUE"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if query:
|
||||||
|
clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)")
|
||||||
|
like = f'%{query}%'
|
||||||
|
params.extend([like, like, like])
|
||||||
|
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT i.id, i.part_number, i.name, i.brand, i.price_1,
|
||||||
|
COALESCE(s.stock, 0) AS stock,
|
||||||
|
i.unit, i.catalog_part_id
|
||||||
|
FROM inventory i
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT inventory_id, SUM(quantity) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
GROUP BY inventory_id
|
||||||
|
) s ON s.inventory_id = i.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY i.name
|
||||||
|
LIMIT %s
|
||||||
|
""", params + [limit])
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': r[0],
|
||||||
|
'part_number': r[1],
|
||||||
|
'name': r[2],
|
||||||
|
'brand': r[3] or '',
|
||||||
|
'price': float(r[4]) if r[4] else None,
|
||||||
|
'stock_hint': 'En stock' if r[5] > 0 else 'Agotado',
|
||||||
|
'unit': r[6] or 'PZA',
|
||||||
|
'catalog_part_id': r[7],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Peer fan-out query ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _query_one_peer(peer: dict, query: str, limit: int) -> dict:
|
||||||
|
"""Send a search request to one peer instance. Returns results or error."""
|
||||||
|
url = peer['url'].rstrip('/') + '/pos/api/peer/inventory'
|
||||||
|
params = {'limit': limit}
|
||||||
|
if query:
|
||||||
|
params['q'] = query
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params, timeout=get_timeout())
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
# Tag each result with the source instance name
|
||||||
|
items = data.get('data', [])
|
||||||
|
for item in items:
|
||||||
|
item['source_instance'] = peer['name']
|
||||||
|
item['source_url'] = peer['url']
|
||||||
|
return {'ok': True, 'name': peer['name'], 'data': items}
|
||||||
|
else:
|
||||||
|
return {'ok': False, 'name': peer['name'], 'error': f'HTTP {resp.status_code}'}
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return {'ok': False, 'name': peer['name'], 'error': 'timeout'}
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return {'ok': False, 'name': peer['name'], 'error': 'offline'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'name': peer['name'], 'error': str(e)[:100]}
|
||||||
|
|
||||||
|
|
||||||
|
def search_all_peers(tenant_conn, query: str = None, limit: int = 50) -> dict:
|
||||||
|
"""Search local inventory + all enabled peers in parallel.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"local": { "name": "...", "data": [...] },
|
||||||
|
"peers": [
|
||||||
|
{"name": "Refac B", "data": [...], "ok": True},
|
||||||
|
{"name": "Refac C", "data": [...], "ok": True},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"merged": [...], # all results combined, local first
|
||||||
|
"total": N,
|
||||||
|
"errors": [...] # peers that failed
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
peers = get_peers()
|
||||||
|
|
||||||
|
# Local results
|
||||||
|
local_data = get_local_inventory(tenant_conn, query=query, limit=limit)
|
||||||
|
for item in local_data:
|
||||||
|
item['source_instance'] = get_instance_name()
|
||||||
|
item['source_url'] = 'local'
|
||||||
|
|
||||||
|
# Fan-out to peers in parallel
|
||||||
|
peer_results = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if peers:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(_query_one_peer, p, query, limit): p
|
||||||
|
for p in peers
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
if result['ok']:
|
||||||
|
peer_results.append(result)
|
||||||
|
else:
|
||||||
|
errors.append(result)
|
||||||
|
print(f'[peer] {result["name"]}: {result["error"]}')
|
||||||
|
|
||||||
|
# Merge: local first, then peers (sorted by name within each source)
|
||||||
|
merged = list(local_data)
|
||||||
|
for pr in peer_results:
|
||||||
|
merged.extend(pr.get('data', []))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'local': {
|
||||||
|
'name': get_instance_name(),
|
||||||
|
'data': local_data,
|
||||||
|
'count': len(local_data),
|
||||||
|
},
|
||||||
|
'peers': peer_results,
|
||||||
|
'merged': merged,
|
||||||
|
'total': len(merged),
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Health check for the peer network ───────────────────────────────────
|
||||||
|
|
||||||
|
def check_peer_health() -> list[dict]:
|
||||||
|
"""Ping all peers and return status. Useful for the admin dashboard."""
|
||||||
|
peers = get_peers()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def _ping(peer):
|
||||||
|
try:
|
||||||
|
url = peer['url'].rstrip('/') + '/pos/api/peer/health'
|
||||||
|
resp = requests.get(url, timeout=get_timeout())
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
'name': peer['name'],
|
||||||
|
'url': peer['url'],
|
||||||
|
'status': 'online',
|
||||||
|
'instance_name': data.get('instance_name'),
|
||||||
|
'inventory_count': data.get('inventory_count'),
|
||||||
|
}
|
||||||
|
return {'name': peer['name'], 'url': peer['url'], 'status': f'error:{resp.status_code}'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'name': peer['name'], 'url': peer['url'], 'status': f'offline:{str(e)[:50]}'}
|
||||||
|
|
||||||
|
if peers:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
|
||||||
|
results = list(executor.map(_ping, peers))
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -105,6 +105,93 @@ def generate_ticket(sale_data, business_info, width=80):
|
|||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_quotation_ticket(quote_data, business_info, width=80):
|
||||||
|
"""Generate ESC/POS bytes for a quotation ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quote_data: dict with keys: id, items[{name, part_number, quantity, unit_price, subtotal}],
|
||||||
|
subtotal, tax_total, total, valid_until, customer_name, wa_phone, created_at
|
||||||
|
business_info: dict with name, rfc, address
|
||||||
|
width: 58 or 80 (mm)
|
||||||
|
|
||||||
|
Returns: bytes ready to send to printer
|
||||||
|
"""
|
||||||
|
chars = 32 if width == 58 else 48
|
||||||
|
buf = bytearray()
|
||||||
|
buf += INIT
|
||||||
|
|
||||||
|
# Header
|
||||||
|
buf += ALIGN_CENTER
|
||||||
|
buf += LARGE_SIZE
|
||||||
|
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += NORMAL_SIZE
|
||||||
|
if business_info.get('rfc'):
|
||||||
|
buf += (business_info['rfc'] + '\n').encode('cp437', errors='replace')
|
||||||
|
if business_info.get('address'):
|
||||||
|
buf += (business_info['address'] + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += b'\n'
|
||||||
|
|
||||||
|
# Title
|
||||||
|
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||||
|
buf += 'COTIZACION\n'.encode('cp437', errors='replace')
|
||||||
|
buf += NORMAL_SIZE + BOLD_OFF
|
||||||
|
buf += b'\n'
|
||||||
|
|
||||||
|
# Folio + date
|
||||||
|
buf += ALIGN_LEFT
|
||||||
|
buf += BOLD_ON
|
||||||
|
buf += f'Folio: COT-{quote_data.get("id", "")}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_OFF
|
||||||
|
buf += f'Fecha: {str(quote_data.get("created_at", ""))[:10]}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += f'Vigencia: {quote_data.get("valid_until", "7 dias")}\n'.encode('cp437', errors='replace')
|
||||||
|
if quote_data.get('customer_name'):
|
||||||
|
buf += f'Cliente: {quote_data["customer_name"]}\n'.encode('cp437', errors='replace')
|
||||||
|
if quote_data.get('wa_phone'):
|
||||||
|
buf += f'WhatsApp: {quote_data["wa_phone"]}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Column header
|
||||||
|
buf += BOLD_ON
|
||||||
|
hdr = _format_line('Cant Descripcion', 'Importe', chars)
|
||||||
|
buf += (hdr + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_OFF
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Items
|
||||||
|
for item in quote_data.get('items', []):
|
||||||
|
name = item.get('name', '')[:chars - 10]
|
||||||
|
part_no = item.get('part_number', '')
|
||||||
|
qty = item.get('quantity', 1)
|
||||||
|
subtotal = item.get('subtotal', 0)
|
||||||
|
buf += f'{qty}x {name}\n'.encode('cp437', errors='replace')
|
||||||
|
if part_no:
|
||||||
|
buf += f' #{part_no}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ALIGN_RIGHT
|
||||||
|
buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ALIGN_LEFT
|
||||||
|
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Totals
|
||||||
|
buf += ALIGN_RIGHT
|
||||||
|
buf += _total_line('Subtotal:', quote_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
|
||||||
|
buf += _total_line('IVA:', quote_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||||
|
buf += _total_line('TOTAL:', quote_data.get('total', 0), chars).encode('cp437', errors='replace')
|
||||||
|
buf += NORMAL_SIZE + BOLD_OFF
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
buf += b'\n'
|
||||||
|
buf += ALIGN_CENTER
|
||||||
|
buf += 'Esta cotizacion no es comprobante fiscal\n'.encode('cp437', errors='replace')
|
||||||
|
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
|
||||||
|
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
||||||
|
buf += b'\n\n\n'
|
||||||
|
buf += PARTIAL_CUT
|
||||||
|
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
def _format_line(left, right, width):
|
def _format_line(left, right, width):
|
||||||
"""Pad a left-right line to fill the ticket width."""
|
"""Pad a left-right line to fill the ticket width."""
|
||||||
space = width - len(left) - len(right)
|
space = width - len(left) - len(right)
|
||||||
|
|||||||
@@ -14,8 +14,20 @@ def decode_vin(vin):
|
|||||||
return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."}
|
return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."}
|
||||||
|
|
||||||
url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json"
|
url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json"
|
||||||
resp = requests.get(url, timeout=10)
|
# NHTSA's free API can be slow (5-30s). Retry once on timeout.
|
||||||
|
import time
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=25)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
break
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
return {"error": "El servidor NHTSA no respondio. Intenta de nuevo en unos segundos."}
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return {"error": f"Error de conexion con NHTSA: {str(e)[:100]}"}
|
||||||
data = resp.json()["Results"][0]
|
data = resp.json()["Results"][0]
|
||||||
|
|
||||||
error_text = data.get("ErrorText", "") or ""
|
error_text = data.get("ErrorText", "") or ""
|
||||||
|
|||||||
284
pos/services/wa_quotation.py
Normal file
284
pos/services/wa_quotation.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
WhatsApp Quotation Service — conversational quote builder.
|
||||||
|
|
||||||
|
Tracks per-phone "open quotations" so a customer can ask about multiple
|
||||||
|
parts over several messages and receive a single formatted quotation at
|
||||||
|
the end.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Customer asks about a part → bot shows local inventory match
|
||||||
|
2. Customer says "cotizar" / "agregar" → last-shown part added to quote
|
||||||
|
3. Repeat for more parts
|
||||||
|
4. Customer says "enviar cotización" / "listo" → formatted quote sent
|
||||||
|
5. Customer says "limpiar" / "nueva cotización" → quote cleared
|
||||||
|
|
||||||
|
The quotation is stored in the tenant's existing `quotations` +
|
||||||
|
`quotation_items` tables so it also appears in the POS quotation list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Intent detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Commands the customer can type (case-insensitive, accent-insensitive)
|
||||||
|
# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity
|
||||||
|
# with "si" after a quotation was sent (which means "confirm order").
|
||||||
|
_ADD_PATTERNS = re.compile(
|
||||||
|
r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|'
|
||||||
|
r'dame ese|lo quiero|me lo apartas|si.?cotiza)$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEND_PATTERNS = re.compile(
|
||||||
|
r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|'
|
||||||
|
r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|'
|
||||||
|
r'mi cotizaci[oó]n|total|cuanto es)$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_CLEAR_PATTERNS = re.compile(
|
||||||
|
r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# "si", "va", "confirmo" — confirm the quotation (close it as accepted)
|
||||||
|
_CONFIRM_PATTERNS = re.compile(
|
||||||
|
r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_QTY_PATTERN = re.compile(
|
||||||
|
r'^(cotizar|agregar|dame|quiero)\s+(\d+)$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_quote_intent(text, has_open_quote=False):
|
||||||
|
"""Detect if the message is a quotation command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: the user's message
|
||||||
|
has_open_quote: True if this phone has an active quotation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
('add', quantity) — add last part to quote
|
||||||
|
('send', None) — send the full quotation
|
||||||
|
('clear', None) — clear the quotation
|
||||||
|
('confirm', None) — confirm/accept the quotation
|
||||||
|
(None, None) — not a quote command, pass to AI
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
t = text.strip()
|
||||||
|
|
||||||
|
# Check for quantity: "cotizar 3", "agregar 5"
|
||||||
|
qty_match = _QTY_PATTERN.match(t)
|
||||||
|
if qty_match:
|
||||||
|
return 'add', int(qty_match.group(2))
|
||||||
|
|
||||||
|
if _ADD_PATTERNS.match(t):
|
||||||
|
return 'add', 1
|
||||||
|
|
||||||
|
if _SEND_PATTERNS.match(t):
|
||||||
|
return 'send', None
|
||||||
|
|
||||||
|
if _CLEAR_PATTERNS.match(t):
|
||||||
|
return 'clear', None
|
||||||
|
|
||||||
|
# "si" / "va" / "confirmo" — only counts as 'confirm' when there's
|
||||||
|
# an open quote. Otherwise pass to the AI as normal conversation.
|
||||||
|
if has_open_quote and _CONFIRM_PATTERNS.match(t):
|
||||||
|
return 'confirm', None
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_quotation(tenant_conn, phone):
|
||||||
|
"""Mark the open quotation as confirmed/accepted."""
|
||||||
|
qid = get_open_quotation(tenant_conn, phone)
|
||||||
|
if not qid:
|
||||||
|
return None
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
clear_last_shown(phone)
|
||||||
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
# ─── In-memory last-shown-part per phone ─────────────────────────────
|
||||||
|
# Tracks what part the bot last showed so "cotizar" knows what to add.
|
||||||
|
# Key: phone (clean, no @lid). Value: dict with inventory item info.
|
||||||
|
|
||||||
|
_last_shown = {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_last_shown_part(phone, part_info):
|
||||||
|
"""Store the last part shown to this phone number.
|
||||||
|
|
||||||
|
part_info: dict with keys inventory_id, part_number, name, brand,
|
||||||
|
price, stock, unit
|
||||||
|
"""
|
||||||
|
_last_shown[phone] = part_info
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_shown_part(phone):
|
||||||
|
return _last_shown.get(phone)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_last_shown(phone):
|
||||||
|
_last_shown.pop(phone, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Quotation CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_open_quotation(tenant_conn, phone):
|
||||||
|
"""Find an active quotation for this phone, or None."""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM quotations
|
||||||
|
WHERE notes LIKE %s AND status = 'active'
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
""", (f'%WA:{phone}%',))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_quotation(tenant_conn, phone):
|
||||||
|
"""Create a new quotation tagged with this phone number."""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until)
|
||||||
|
VALUES (0, 0, 0, 'active', %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (f'WA:{phone}', date.today() + timedelta(days=7)))
|
||||||
|
qid = cur.fetchone()[0]
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1):
|
||||||
|
"""Add a part to an existing quotation and recalculate totals."""
|
||||||
|
price = float(part_info.get('price') or 0)
|
||||||
|
tax_rate = float(part_info.get('tax_rate') or 0.16)
|
||||||
|
subtotal = round(price * quantity, 2)
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO quotation_items
|
||||||
|
(quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
quote_id,
|
||||||
|
part_info.get('inventory_id'),
|
||||||
|
part_info.get('part_number', ''),
|
||||||
|
part_info.get('name', ''),
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
tax_rate,
|
||||||
|
subtotal,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Recalculate totals
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(SUM(subtotal), 0),
|
||||||
|
COALESCE(SUM(subtotal * tax_rate), 0)
|
||||||
|
FROM quotation_items WHERE quotation_id = %s
|
||||||
|
""", (quote_id,))
|
||||||
|
sub, tax = cur.fetchone()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (sub, tax, round(sub + tax, 2), quote_id))
|
||||||
|
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return subtotal
|
||||||
|
|
||||||
|
|
||||||
|
def get_quotation_detail(tenant_conn, quote_id):
|
||||||
|
"""Return full quotation with items."""
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, subtotal, tax_total, total, status, valid_until, created_at
|
||||||
|
FROM quotations WHERE id = %s
|
||||||
|
""", (quote_id,))
|
||||||
|
q = cur.fetchone()
|
||||||
|
if not q:
|
||||||
|
cur.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT part_number, name, quantity, unit_price, tax_rate, subtotal
|
||||||
|
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||||
|
""", (quote_id,))
|
||||||
|
items = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': q[0],
|
||||||
|
'subtotal': float(q[1]),
|
||||||
|
'tax_total': float(q[2]),
|
||||||
|
'total': float(q[3]),
|
||||||
|
'status': q[4],
|
||||||
|
'valid_until': str(q[5]) if q[5] else None,
|
||||||
|
'created_at': str(q[6]) if q[6] else None,
|
||||||
|
'items': [{
|
||||||
|
'part_number': it[0],
|
||||||
|
'name': it[1],
|
||||||
|
'quantity': it[2],
|
||||||
|
'unit_price': float(it[3]),
|
||||||
|
'tax_rate': float(it[4]),
|
||||||
|
'subtotal': float(it[5]),
|
||||||
|
} for it in items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_quotation(tenant_conn, phone):
|
||||||
|
"""Cancel the open quotation for this phone."""
|
||||||
|
qid = get_open_quotation(tenant_conn, phone)
|
||||||
|
if qid:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
clear_last_shown(phone)
|
||||||
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Format quotation for WhatsApp ──────────────────────────────────
|
||||||
|
|
||||||
|
def format_quotation_wa(detail):
|
||||||
|
"""Format a quotation as a WhatsApp-friendly text message."""
|
||||||
|
if not detail or not detail.get('items'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'📄 *COTIZACIÓN #{detail["id"]}*',
|
||||||
|
f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}',
|
||||||
|
f'Vigencia: {detail.get("valid_until") or "7 días"}',
|
||||||
|
'',
|
||||||
|
'─────────────────────',
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, item in enumerate(detail['items'], 1):
|
||||||
|
qty = item['quantity']
|
||||||
|
price = item['unit_price']
|
||||||
|
sub = item['subtotal']
|
||||||
|
lines.append(f'{i}. {item["name"]}')
|
||||||
|
lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}')
|
||||||
|
|
||||||
|
lines.append('─────────────────────')
|
||||||
|
lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}')
|
||||||
|
lines.append(f' IVA: ${detail["tax_total"]:,.2f}')
|
||||||
|
lines.append(f' *TOTAL: ${detail["total"]:,.2f}*')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('_Responde "si" para confirmar el pedido._')
|
||||||
|
lines.append('_Responde "limpiar" para empezar de nuevo._')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
@@ -55,12 +55,63 @@ def logout():
|
|||||||
|
|
||||||
|
|
||||||
def process_incoming(webhook_data):
|
def process_incoming(webhook_data):
|
||||||
|
"""Extract a normalized dict from a Baileys webhook payload.
|
||||||
|
|
||||||
|
Supports text messages, image messages, audio (voice notes), and video.
|
||||||
|
Media content comes pre-downloaded as base64 from the bridge so Python
|
||||||
|
doesn't have to re-authenticate with WhatsApp servers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys:
|
||||||
|
phone — numeric phone (no JID suffix)
|
||||||
|
jid — full remote JID (may be @s.whatsapp.net or @lid)
|
||||||
|
text — text content (plain text or media caption)
|
||||||
|
from_me — bool, True if we sent the message
|
||||||
|
message_id — WhatsApp message ID
|
||||||
|
media_kind — 'text' | 'image' | 'audio' | 'video'
|
||||||
|
media_base64 — base64 string if media, else None
|
||||||
|
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
|
||||||
|
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
|
||||||
|
"""
|
||||||
data = webhook_data.get('data', {})
|
data = webhook_data.get('data', {})
|
||||||
key = data.get('key', {})
|
key = data.get('key', {})
|
||||||
message = data.get('message', {})
|
message = data.get('message', {})
|
||||||
|
|
||||||
|
# remoteJid can be phone@s.whatsapp.net or LID@lid
|
||||||
|
remote_jid = key.get('remoteJid', '')
|
||||||
|
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||||||
|
|
||||||
|
# The bridge now classifies and passes these extra fields. Fall back to
|
||||||
|
# the old parsing if they're missing (older bridge version).
|
||||||
|
media_kind = data.get('media_kind', 'text')
|
||||||
|
media_base64 = data.get('media_base64')
|
||||||
|
media_mimetype = data.get('media_mimetype')
|
||||||
|
media_caption = data.get('media_caption') or ''
|
||||||
|
is_voice_note = bool(data.get('media_ptt'))
|
||||||
|
push_name = data.get('push_name') or ''
|
||||||
|
|
||||||
|
# Text content:
|
||||||
|
# - For 'text' messages → conversation or extendedTextMessage
|
||||||
|
# - For 'image'/'video' → the caption (may be empty)
|
||||||
|
# - For 'audio' → empty (filled in later by Whisper transcription)
|
||||||
|
if media_kind == 'text':
|
||||||
|
text = (
|
||||||
|
message.get('conversation', '')
|
||||||
|
or message.get('extendedTextMessage', {}).get('text', '')
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = media_caption
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'phone': key.get('remoteJid', '').replace('@s.whatsapp.net', ''),
|
'phone': phone,
|
||||||
'text': message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', ''),
|
'jid': remote_jid,
|
||||||
|
'text': text,
|
||||||
'from_me': key.get('fromMe', False),
|
'from_me': key.get('fromMe', False),
|
||||||
'message_id': key.get('id', ''),
|
'message_id': key.get('id', ''),
|
||||||
|
'media_kind': media_kind,
|
||||||
|
'media_base64': media_base64,
|
||||||
|
'media_mimetype': media_mimetype,
|
||||||
|
'is_voice_note': is_voice_note,
|
||||||
|
'push_name': push_name,
|
||||||
}
|
}
|
||||||
|
|||||||
151
pos/services/whisper_local.py
Normal file
151
pos/services/whisper_local.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Local Whisper transcription service.
|
||||||
|
|
||||||
|
Uses faster-whisper (a CTranslate2-based port of OpenAI Whisper) for
|
||||||
|
transcribing short audio clips (WhatsApp voice notes) on the CPU.
|
||||||
|
|
||||||
|
Runs fully offline after the first model download. No API keys, no
|
||||||
|
per-minute cost. Model is lazy-loaded on first call and cached in memory
|
||||||
|
for the lifetime of the process.
|
||||||
|
|
||||||
|
Default model: 'tiny' — the smallest and fastest variant (~75 MB), good
|
||||||
|
enough for conversational Spanish. Change WHISPER_MODEL below to 'base'
|
||||||
|
(150 MB, slightly better accuracy) or 'small' (500 MB, noticeably better)
|
||||||
|
if you have the RAM and don't mind 2-3x slower inference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64 as _b64
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────────────────
|
||||||
|
# 'base' is the sweet spot for Mexican Spanish voice notes on CPU:
|
||||||
|
# tiny (75 MB) — too small, misses words in noisy/robot audio
|
||||||
|
# base (150 MB) — good accuracy, ~2s per 30s clip on a modern CPU ← default
|
||||||
|
# small (500 MB) — best accuracy, ~5s per 30s clip, worth it if RAM permits
|
||||||
|
WHISPER_MODEL = "base"
|
||||||
|
WHISPER_DEVICE = "cpu"
|
||||||
|
WHISPER_COMPUTE = "int8" # int8 quantization — CPU-friendly, minimal quality loss
|
||||||
|
|
||||||
|
# ─── Lazy singleton model loader ─────────────────────────────────────────
|
||||||
|
_model = None
|
||||||
|
_model_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model():
|
||||||
|
"""Load the Whisper model on first use. Thread-safe."""
|
||||||
|
global _model
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
with _model_lock:
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
print(f"[whisper] Loading {WHISPER_MODEL} model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...")
|
||||||
|
_model = WhisperModel(
|
||||||
|
WHISPER_MODEL,
|
||||||
|
device=WHISPER_DEVICE,
|
||||||
|
compute_type=WHISPER_COMPUTE,
|
||||||
|
)
|
||||||
|
print("[whisper] Model ready.")
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def transcribe_audio_base64(audio_base64: str, mimetype: str = "audio/ogg",
|
||||||
|
language: str = "es") -> str | None:
|
||||||
|
"""Transcribe a base64-encoded audio blob to text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_base64: Raw base64 string (no data: prefix).
|
||||||
|
mimetype: MIME type from the sender (e.g. 'audio/ogg' for WA voice notes).
|
||||||
|
language: ISO 639-1 code to bias the model. 'es' for Spanish MX.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The transcribed text, or None if transcription fails or is empty.
|
||||||
|
"""
|
||||||
|
if not audio_base64:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Decode base64 → write to a temp file with the right extension so
|
||||||
|
# ffmpeg (invoked by faster-whisper/CTranslate2) picks the decoder.
|
||||||
|
ext = _extension_for_mimetype(mimetype)
|
||||||
|
try:
|
||||||
|
audio_bytes = _b64.b64decode(audio_base64)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[whisper] base64 decode failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmp_in = None
|
||||||
|
tmp_wav = None
|
||||||
|
try:
|
||||||
|
# Write the original audio to a temp file
|
||||||
|
tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
|
||||||
|
tmp_in.write(audio_bytes)
|
||||||
|
tmp_in.close()
|
||||||
|
|
||||||
|
# WhatsApp voice notes are OGG/Opus — faster-whisper can handle it
|
||||||
|
# via its pyav decoder, but converting to 16kHz mono WAV first is
|
||||||
|
# more reliable across formats and ~2x faster.
|
||||||
|
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||||
|
tmp_wav.close()
|
||||||
|
rc = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", tmp_in.name,
|
||||||
|
"-ar", "16000", "-ac", "1",
|
||||||
|
"-f", "wav", tmp_wav.name],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if rc.returncode != 0:
|
||||||
|
print(f"[whisper] ffmpeg conversion failed: {rc.stderr.decode()[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Run Whisper
|
||||||
|
# - beam_size=5 for better accuracy on short/noisy clips
|
||||||
|
# - no VAD filter (was trimming real speech in some tests)
|
||||||
|
# - condition_on_previous_text=False for short independent clips
|
||||||
|
model = _get_model()
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
tmp_wav.name,
|
||||||
|
language=language,
|
||||||
|
beam_size=5,
|
||||||
|
vad_filter=False,
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
)
|
||||||
|
text = " ".join(s.text.strip() for s in segments if s.text.strip())
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"[whisper] ({info.language}, {info.duration:.1f}s) → {text[:100]}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[whisper] transcription error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for f in (tmp_in, tmp_wav):
|
||||||
|
if f:
|
||||||
|
try:
|
||||||
|
os.unlink(f.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _extension_for_mimetype(mimetype: str) -> str:
|
||||||
|
"""Map a MIME type to a file extension ffmpeg understands."""
|
||||||
|
m = (mimetype or "").lower()
|
||||||
|
if "opus" in m or "ogg" in m:
|
||||||
|
return ".ogg"
|
||||||
|
if "mp3" in m or "mpeg" in m:
|
||||||
|
return ".mp3"
|
||||||
|
if "mp4" in m or "aac" in m:
|
||||||
|
return ".m4a"
|
||||||
|
if "wav" in m:
|
||||||
|
return ".wav"
|
||||||
|
if "webm" in m:
|
||||||
|
return ".webm"
|
||||||
|
return ".ogg" # WhatsApp voice notes are usually OGG/Opus
|
||||||
683
pos/static/css/pos-glass.css
Normal file
683
pos/static/css/pos-glass.css
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
POS-GLASS.CSS — Pixel-Perfect glassmorphism overlay for Nexus POS
|
||||||
|
Load AFTER tokens.css. Applies glass effects, glow, 3D buttons,
|
||||||
|
and animations to all POS pages without modifying inline styles.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ── Hidden scrollbar (global) ── */
|
||||||
|
html { scrollbar-width: none; }
|
||||||
|
html::-webkit-scrollbar { width: 0; }
|
||||||
|
|
||||||
|
/* ── Smooth font rendering ── */
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SIDEBAR — Glass treatment
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.pos-sidebar {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid var(--glass-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__logo {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__logo-text {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow under logo text */
|
||||||
|
.sidebar__logo-text::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gradient-accent);
|
||||||
|
border-radius: 1px;
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav items — hover glow */
|
||||||
|
.sidebar__nav a,
|
||||||
|
.sidebar__nav-item,
|
||||||
|
.sidebar .nav-item {
|
||||||
|
transition: all 0.25s var(--ease-out) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav a:hover,
|
||||||
|
.sidebar__nav-item:hover,
|
||||||
|
.sidebar .nav-item:hover {
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav a.active,
|
||||||
|
.sidebar__nav-item.active,
|
||||||
|
.sidebar .nav-item.active {
|
||||||
|
box-shadow: 0 0 16px var(--glow-color-soft), inset 0 0 0 1px var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
THEME BAR — Glass
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.theme-bar {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--glass-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CARDS — Glass with glow hover
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.kpi-card,
|
||||||
|
.table-card,
|
||||||
|
.card,
|
||||||
|
.stat-card,
|
||||||
|
.chart-card,
|
||||||
|
.alert-card,
|
||||||
|
.config-card,
|
||||||
|
.fleet-card,
|
||||||
|
.report-card,
|
||||||
|
.invoice-card,
|
||||||
|
.customer-card,
|
||||||
|
.panel {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
transition: all 0.3s var(--ease-out) !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent top-line on hover */
|
||||||
|
.kpi-card::before,
|
||||||
|
.table-card::before,
|
||||||
|
.chart-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gradient-accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 0.4s var(--ease-out);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card:hover::before,
|
||||||
|
.table-card:hover::before,
|
||||||
|
.chart-card:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card:hover,
|
||||||
|
.table-card:hover,
|
||||||
|
.card:hover,
|
||||||
|
.stat-card:hover,
|
||||||
|
.chart-card:hover,
|
||||||
|
.config-card:hover,
|
||||||
|
.fleet-card:hover,
|
||||||
|
.report-card:hover {
|
||||||
|
border-color: var(--color-border-accent) !important;
|
||||||
|
box-shadow: 0 4px 20px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI card accent bar — add glow */
|
||||||
|
.kpi-card__accent-bar {
|
||||||
|
box-shadow: 0 0 8px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BUTTONS — 3D depth effect
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Primary buttons */
|
||||||
|
.btn--primary,
|
||||||
|
button.primary,
|
||||||
|
.btn-primary,
|
||||||
|
input[type="submit"],
|
||||||
|
button[type="submit"] {
|
||||||
|
background: var(--gradient-accent) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 3px 0 var(--color-primary-active),
|
||||||
|
0 4px 10px var(--glow-color-soft) !important;
|
||||||
|
transition: all 0.25s var(--ease-out) !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover,
|
||||||
|
button.primary:hover,
|
||||||
|
.btn-primary:hover,
|
||||||
|
input[type="submit"]:hover,
|
||||||
|
button[type="submit"]:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 0 var(--color-primary-active),
|
||||||
|
0 8px 20px var(--glow-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:active,
|
||||||
|
button.primary:active,
|
||||||
|
.btn-primary:active,
|
||||||
|
input[type="submit"]:active,
|
||||||
|
button[type="submit"]:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 1px 0 var(--color-primary-active) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost / secondary buttons — glass */
|
||||||
|
.btn--ghost,
|
||||||
|
.btn--secondary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-ghost,
|
||||||
|
button.secondary {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
transition: all 0.25s var(--ease-out) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost:hover,
|
||||||
|
.btn--secondary:hover,
|
||||||
|
.btn-secondary:hover,
|
||||||
|
.btn-ghost:hover,
|
||||||
|
button.secondary:hover {
|
||||||
|
border-color: var(--color-border-accent) !important;
|
||||||
|
box-shadow: 0 0 16px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
INPUTS — Glass with focus glow
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="search"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="url"],
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
.search-input,
|
||||||
|
.filter-input {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
transition: all 0.25s var(--ease-out) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
input[type="search"]:focus,
|
||||||
|
input[type="tel"]:focus,
|
||||||
|
input[type="date"]:focus,
|
||||||
|
input[type="url"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus,
|
||||||
|
.search-input:focus,
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: var(--color-border-focus) !important;
|
||||||
|
box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 16px var(--glow-color-soft) !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TABLES — Subtle glass rows
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
table thead th {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
|
||||||
|
table tbody tr {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tbody tr:hover {
|
||||||
|
background: var(--glass-highlight) !important;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
MODALS — Glass overlay + glass content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.modal-overlay,
|
||||||
|
.overlay,
|
||||||
|
.modal-backdrop {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal,
|
||||||
|
.modal-content,
|
||||||
|
.modal-dialog,
|
||||||
|
.dialog {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
box-shadow: 0 24px 48px rgba(0,0,0,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TABS — Glass active state
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.tab,
|
||||||
|
.tab-btn,
|
||||||
|
.tabs button {
|
||||||
|
transition: all 0.25s var(--ease-out) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active,
|
||||||
|
.tab-btn.active,
|
||||||
|
.tabs button.active {
|
||||||
|
background: var(--color-primary-muted) !important;
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
border-color: var(--color-border-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BADGES / TAGS — Subtle glow
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.badge,
|
||||||
|
.tag,
|
||||||
|
.status-badge,
|
||||||
|
.pill {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SCROLL REVEAL — Available for any POS page that wants it
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nx-reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
|
||||||
|
}
|
||||||
|
.nx-reveal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; }
|
||||||
|
.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TOAST / NOTIFICATIONS — Glass
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.toast,
|
||||||
|
.notification,
|
||||||
|
.snackbar,
|
||||||
|
.alert {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
DROPDOWN / POPOVER — Glass
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.dropdown-menu,
|
||||||
|
.popover,
|
||||||
|
.autocomplete-list,
|
||||||
|
.suggestion-list {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
STATUS BAR (POS) — Glass
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.status-bar,
|
||||||
|
.pos-status-bar {
|
||||||
|
background: var(--glass-bg-strong) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--glass-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
LOADING SPINNER — Glow animation
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.spinner,
|
||||||
|
.loading-spinner {
|
||||||
|
animation: nx-glow-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
ANIMATIONS — Available keyframes
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@keyframes pos-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply subtle entry animation to main content area */
|
||||||
|
.content,
|
||||||
|
.main-content,
|
||||||
|
main {
|
||||||
|
animation: pos-fade-in 0.4s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
DASHED BORDER ACCENTS (Pixel-Perfect style)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.section-divider,
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px dashed var(--glass-border);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TABLET RESPONSIVE — Adaptive layout for 768px-1024px screens
|
||||||
|
Applied globally to all POS pages via pos-glass.css.
|
||||||
|
Targets iPad (768×1024), Android tablets (800×1280), and similar.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ── Tablet portrait (768-1023px) — sidebar collapses, grids reflow ── */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
|
||||||
|
/* Sidebar collapses to an overlay drawer */
|
||||||
|
.sidebar,
|
||||||
|
.pos-sidebar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
z-index: var(--z-modal) !important;
|
||||||
|
transform: translateX(-100%) !important;
|
||||||
|
transition: transform 0.3s var(--ease-out) !important;
|
||||||
|
width: 260px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open,
|
||||||
|
.pos-sidebar.open {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
box-shadow: 0 0 40px rgba(0,0,0,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
z-index: calc(var(--z-modal) - 1) !important;
|
||||||
|
background: rgba(0,0,0,0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.open {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App shell: full width when sidebar is hidden */
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell > main,
|
||||||
|
.app-shell > .main-content,
|
||||||
|
.app-shell > .content,
|
||||||
|
.main-content,
|
||||||
|
.content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show hamburger button */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly targets — minimum 44px tap area */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.nav-card,
|
||||||
|
.tab-btn,
|
||||||
|
.tab,
|
||||||
|
.part-card,
|
||||||
|
.search-result-item,
|
||||||
|
table tbody tr,
|
||||||
|
.kpi-card {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger text for readability on tablets */
|
||||||
|
.kpi-card__value {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid reflow: 2 columns instead of 3-4 */
|
||||||
|
.kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables: horizontal scroll wrapper on narrow screens */
|
||||||
|
.table-wrap,
|
||||||
|
.table-card {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POS-specific: if the POS has a side panel (cart), stack vertically */
|
||||||
|
.pos-layout {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-layout .pos-cart,
|
||||||
|
.pos-layout .cart-panel {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: 40vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content headers: tighter padding */
|
||||||
|
.content-header,
|
||||||
|
.header,
|
||||||
|
.page-header {
|
||||||
|
padding: var(--space-3) var(--space-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search bar: full width */
|
||||||
|
.search-bar,
|
||||||
|
.search-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode toggle: slightly larger buttons for touch */
|
||||||
|
.mode-toggle button {
|
||||||
|
padding: 6px 14px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vehicle selector dropdowns: stack on smaller tablets */
|
||||||
|
.vehicle-selector__inner,
|
||||||
|
.vehicle-selector .vs-group {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-selector .vs-arrow {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-selector .vs-select {
|
||||||
|
min-width: 130px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phone portrait (< 768px) — single column, max simplification ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 85vw !important;
|
||||||
|
max-width: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid,
|
||||||
|
.nav-grid,
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card__value {
|
||||||
|
font-size: 1.3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack the mode toggle buttons vertically if tight */
|
||||||
|
.mode-toggle {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide non-essential UI to save space */
|
||||||
|
.header__store-badge,
|
||||||
|
.vs-vin-divider {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width modals */
|
||||||
|
.modal-content {
|
||||||
|
max-width: 95vw !important;
|
||||||
|
margin: var(--space-3) !important;
|
||||||
|
padding: var(--space-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables: force readable font size */
|
||||||
|
table {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th,
|
||||||
|
table td {
|
||||||
|
padding: var(--space-2) var(--space-2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Landscape tablet (height < 600px with wide screen) ── */
|
||||||
|
@media (max-height: 600px) and (min-width: 768px) {
|
||||||
|
/* Reduce vertical padding for landscape tablet use */
|
||||||
|
.kpi-grid {
|
||||||
|
gap: var(--space-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard,
|
||||||
|
.main-content,
|
||||||
|
.content {
|
||||||
|
padding: var(--space-3) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Touch device hints ── */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
/* Remove hover-only effects on touch devices — they cause sticky states */
|
||||||
|
.kpi-card:hover,
|
||||||
|
.nav-card:hover,
|
||||||
|
.part-card:hover,
|
||||||
|
.table-card:hover,
|
||||||
|
.card:hover {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets for interactive elements */
|
||||||
|
.sidebar__nav a,
|
||||||
|
.sidebar__nav-item,
|
||||||
|
.sidebar .nav-item {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
min-height: 48px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll momentum on iOS */
|
||||||
|
.table-wrap,
|
||||||
|
.main-content,
|
||||||
|
.content,
|
||||||
|
.parts-grid,
|
||||||
|
.nav-grid {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable text selection on buttons (prevents accidental blue highlight on long tap) */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.nav-card,
|
||||||
|
.tab-btn {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
PRINT — Disable glass effects for printing
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.theme-bar,
|
||||||
|
.kpi-card,
|
||||||
|
.table-card,
|
||||||
|
.card,
|
||||||
|
.modal,
|
||||||
|
.modal-content,
|
||||||
|
table thead th,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: #fff !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: #ccc !important;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -558,6 +558,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
GLASSMORPHISM TOKENS
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
[data-theme="industrial"] {
|
||||||
|
--glass-bg: rgba(26, 26, 26, 0.70);
|
||||||
|
--glass-bg-strong: rgba(26, 26, 26, 0.85);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
--glass-highlight: rgba(245, 166, 35, 0.06);
|
||||||
|
|
||||||
|
--glow-color: rgba(245, 166, 35, 0.40);
|
||||||
|
--glow-color-soft: rgba(245, 166, 35, 0.15);
|
||||||
|
--glow-color-strong: rgba(245, 166, 35, 0.60);
|
||||||
|
|
||||||
|
--gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%);
|
||||||
|
--gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%);
|
||||||
|
|
||||||
|
--canvas-grid-color: rgba(255, 255, 255, 0.06);
|
||||||
|
--canvas-star-color: rgba(245, 166, 35, 0.30);
|
||||||
|
--canvas-glow-color: rgba(245, 166, 35, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="modern"] {
|
||||||
|
--glass-bg: rgba(248, 249, 255, 0.70);
|
||||||
|
--glass-bg-strong: rgba(248, 249, 255, 0.85);
|
||||||
|
--glass-border: rgba(26, 26, 46, 0.08);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
--glass-highlight: rgba(255, 107, 53, 0.04);
|
||||||
|
|
||||||
|
--glow-color: rgba(255, 107, 53, 0.35);
|
||||||
|
--glow-color-soft: rgba(255, 107, 53, 0.12);
|
||||||
|
--glow-color-strong: rgba(255, 107, 53, 0.55);
|
||||||
|
|
||||||
|
--gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%);
|
||||||
|
--gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%);
|
||||||
|
|
||||||
|
--canvas-grid-color: rgba(26, 26, 46, 0.05);
|
||||||
|
--canvas-star-color: rgba(255, 107, 53, 0.20);
|
||||||
|
--canvas-glow-color: rgba(255, 107, 53, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
ANIMATION KEYFRAMES
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@keyframes nx-fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-glow-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); }
|
||||||
|
50% { box-shadow: 0 0 40px var(--glow-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nx-shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
END OF TOKENS FILE
|
END OF TOKENS FILE
|
||||||
nexus-autoparts-design/tokens/tokens.css
|
nexus-autoparts-design/tokens/tokens.css
|
||||||
|
|||||||
@@ -390,7 +390,37 @@ const Accounting = (() => {
|
|||||||
|
|
||||||
// ---- Exportar placeholder ----
|
// ---- Exportar placeholder ----
|
||||||
function exportarContabilidad() {
|
function exportarContabilidad() {
|
||||||
alert('Exportar: proximamente');
|
// Find the first visible table in the active accounting tab and export as CSV
|
||||||
|
var tables = document.querySelectorAll('table');
|
||||||
|
var table = null;
|
||||||
|
for (var i = 0; i < tables.length; i++) {
|
||||||
|
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
|
||||||
|
table = tables[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!table) {
|
||||||
|
alert('No hay datos para exportar en la vista actual.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = [];
|
||||||
|
var ths = table.querySelectorAll('thead th');
|
||||||
|
if (ths.length) {
|
||||||
|
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||||
|
}
|
||||||
|
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||||
|
var cells = tr.querySelectorAll('td');
|
||||||
|
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||||
|
});
|
||||||
|
if (rows.length <= 1) { alert('Sin datos para exportar.'); return; }
|
||||||
|
var csv = rows.join('\n');
|
||||||
|
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'contabilidad_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Nueva Poliza modal ----
|
// ---- Nueva Poliza modal ----
|
||||||
|
|||||||
@@ -49,15 +49,77 @@
|
|||||||
|
|
||||||
// ─── Navigation State ───
|
// ─── Navigation State ───
|
||||||
var nav = {
|
var nav = {
|
||||||
level: 'brands', // brands|models|years|engines|categories|groups|parts
|
level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts
|
||||||
brand: null, // {id, name}
|
brand: null, // {id, name}
|
||||||
model: null, // {id, name}
|
model: null, // {id, name}
|
||||||
year: null, // {id, year}
|
year: null, // {id, year}
|
||||||
engine: null, // {id_mye, name}
|
engine: null, // {id_mye, name}
|
||||||
|
|
||||||
|
// OEM mode (TecDoc) navigation state — integer IDs
|
||||||
category: null, // {id, name}
|
category: null, // {id, name}
|
||||||
group: null, // {id, name}
|
group: null, // {id, name}
|
||||||
|
partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style)
|
||||||
|
|
||||||
|
// Local mode (Nexpart) navigation state — string slugs.
|
||||||
|
// These live in parallel with category/group/partType so transitioning
|
||||||
|
// between modes doesn't trash the other branch's state.
|
||||||
|
nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total)
|
||||||
|
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
|
||||||
|
nxPartType: null, // {slug, name} ← Nexpart part type (3rd level)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Catalog mode (OEM / Local) ───
|
||||||
|
var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem');
|
||||||
|
|
||||||
|
function updateModeToggleUI() {
|
||||||
|
var btns = document.querySelectorAll('#modeToggle button');
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
if (b.getAttribute('data-mode') === catalogMode) {
|
||||||
|
b.classList.add('is-active');
|
||||||
|
} else {
|
||||||
|
b.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCatalogMode(mode) {
|
||||||
|
if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return;
|
||||||
|
if (mode === catalogMode) return;
|
||||||
|
catalogMode = mode;
|
||||||
|
localStorage.setItem('catalog_mode', mode);
|
||||||
|
updateModeToggleUI();
|
||||||
|
|
||||||
|
// Clear category-and-below state regardless of mode
|
||||||
|
nav.category = nav.group = nav.partType = null;
|
||||||
|
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
if (mode === 'supplies') {
|
||||||
|
// Supplies mode skips the vehicle chain entirely.
|
||||||
|
// Clear the vehicle state for visual clarity and go directly
|
||||||
|
// to the Shop Supplies top-level group list.
|
||||||
|
try { vsClearAll(); } catch (e) {}
|
||||||
|
nav.brand = nav.model = nav.year = nav.engine = null;
|
||||||
|
nav.level = 'categories';
|
||||||
|
loadShopSuppliesGroups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OEM/Local: smart reset — if the user already picked a vehicle,
|
||||||
|
// stay at the categories level. Otherwise reset to brand selection.
|
||||||
|
var hasVehicle = !!(nav.engine && nav.engine.id_mye);
|
||||||
|
if (hasVehicle) {
|
||||||
|
nav.level = 'categories';
|
||||||
|
loadCategoriesForMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { vsClearAll(); } catch (e) {}
|
||||||
|
nav.level = 'brands';
|
||||||
|
nav.brand = nav.model = nav.year = nav.engine = null;
|
||||||
|
loadBrands();
|
||||||
|
}
|
||||||
|
|
||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var currentDetailPart = null;
|
var currentDetailPart = null;
|
||||||
var detailQty = 1;
|
var detailQty = 1;
|
||||||
@@ -82,6 +144,10 @@
|
|||||||
nav.engine = e.state.engine;
|
nav.engine = e.state.engine;
|
||||||
nav.category = e.state.category;
|
nav.category = e.state.category;
|
||||||
nav.group = e.state.group;
|
nav.group = e.state.group;
|
||||||
|
nav.partType = e.state.partType || null;
|
||||||
|
nav.nxGroup = e.state.nxGroup || null;
|
||||||
|
nav.nxSubgroup = e.state.nxSubgroup || null;
|
||||||
|
nav.nxPartType = e.state.nxPartType || null;
|
||||||
currentPage = e.state.page || 1;
|
currentPage = e.state.page || 1;
|
||||||
|
|
||||||
// Reload the correct level
|
// Reload the correct level
|
||||||
@@ -89,8 +155,16 @@
|
|||||||
else if (nav.level === 'models') loadModels();
|
else if (nav.level === 'models') loadModels();
|
||||||
else if (nav.level === 'years') loadYears();
|
else if (nav.level === 'years') loadYears();
|
||||||
else if (nav.level === 'engines') loadEngines();
|
else if (nav.level === 'engines') loadEngines();
|
||||||
else if (nav.level === 'categories') loadCategories();
|
// When restoring from history, dispatch between OEM and Nexpart
|
||||||
else if (nav.level === 'groups') loadGroups();
|
// based on which branch of state is populated — this survives
|
||||||
|
// toggle changes made mid-session.
|
||||||
|
else if (nav.level === 'categories') loadCategoriesForMode();
|
||||||
|
else if (nav.level === 'groups') {
|
||||||
|
if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups();
|
||||||
|
}
|
||||||
|
else if (nav.level === 'part_types') {
|
||||||
|
if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes();
|
||||||
|
}
|
||||||
else if (nav.level === 'parts') loadParts(currentPage);
|
else if (nav.level === 'parts') loadParts(currentPage);
|
||||||
else loadBrands();
|
else loadBrands();
|
||||||
|
|
||||||
@@ -151,8 +225,19 @@
|
|||||||
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
|
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
|
||||||
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
|
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
|
||||||
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
|
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
|
||||||
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
|
|
||||||
if (nav.group) parts.push({ label: nav.group.name, action: null });
|
// The category/group/part_type trio is rendered from EITHER the Nexpart
|
||||||
|
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/
|
||||||
|
// group/partType), depending on which is populated. Only one branch
|
||||||
|
// should be active at a time after a navigation reset.
|
||||||
|
if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' });
|
||||||
|
else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
|
||||||
|
|
||||||
|
if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' });
|
||||||
|
else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' });
|
||||||
|
|
||||||
|
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
|
||||||
|
else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
|
||||||
|
|
||||||
var html = '';
|
var html = '';
|
||||||
for (var i = 0; i < parts.length; i++) {
|
for (var i = 0; i < parts.length; i++) {
|
||||||
@@ -173,8 +258,12 @@
|
|||||||
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
|
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
|
||||||
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
|
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
|
||||||
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
|
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
|
||||||
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); }
|
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); }
|
||||||
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
|
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
|
||||||
|
else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); }
|
||||||
|
// Nexpart-branch breadcrumb actions
|
||||||
|
else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); }
|
||||||
|
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -182,17 +271,33 @@
|
|||||||
function resetNav() {
|
function resetNav() {
|
||||||
nav.level = 'brands';
|
nav.level = 'brands';
|
||||||
pushNavState();
|
pushNavState();
|
||||||
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
|
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
|
||||||
|
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetNavFrom(level) {
|
function resetNavFrom(level) {
|
||||||
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
|
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts'];
|
||||||
var idx = levels.indexOf(level);
|
var idx = levels.indexOf(level);
|
||||||
if (idx <= 0) { resetNav(); return; }
|
if (idx <= 0) { resetNav(); return; }
|
||||||
nav.level = level;
|
nav.level = level;
|
||||||
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
|
// For each level, the corresponding state key(s) to clear.
|
||||||
|
// In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc.
|
||||||
|
// We clear BOTH mode-specific keys at each level so a mode switch mid-navigation
|
||||||
|
// is always clean.
|
||||||
|
var keys = [
|
||||||
|
null, // brands (nothing to clear above)
|
||||||
|
['model'], // models
|
||||||
|
['year'], // years
|
||||||
|
['engine'], // engines
|
||||||
|
['category', 'nxGroup'], // categories ← both OEM + Nexpart
|
||||||
|
['group', 'nxSubgroup'], // groups ← both OEM + Nexpart
|
||||||
|
['partType', 'nxPartType'], // part_types ← both OEM + Nexpart
|
||||||
|
null, // parts
|
||||||
|
];
|
||||||
for (var i = idx; i < keys.length; i++) {
|
for (var i = idx; i < keys.length; i++) {
|
||||||
if (keys[i]) nav[keys[i]] = null;
|
if (!keys[i]) continue;
|
||||||
|
var ks = keys[i];
|
||||||
|
for (var j = 0; j < ks.length; j++) nav[ks[j]] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +326,7 @@
|
|||||||
setupLevelFilter(true);
|
setupLevelFilter(true);
|
||||||
showLoading();
|
showLoading();
|
||||||
|
|
||||||
apiFetch(API + '/brands').then(function (data) {
|
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (!data || !data.data || !data.data.length) {
|
if (!data || !data.data || !data.data.length) {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -317,7 +422,7 @@
|
|||||||
if (data.data.length === 1) {
|
if (data.data.length === 1) {
|
||||||
var e = data.data[0];
|
var e = data.data[0];
|
||||||
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
|
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
|
||||||
loadCategories();
|
loadCategoriesForMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +438,7 @@
|
|||||||
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
|
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
|
||||||
loadCategories();
|
loadCategoriesForMode();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -389,32 +494,345 @@
|
|||||||
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
|
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
|
||||||
|
nav.partType = null; // reset deeper levels
|
||||||
|
loadPartTypes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Part Types (3rd subcategory level — Nexpart-style) ───
|
||||||
|
function loadPartTypes() {
|
||||||
|
nav.level = 'part_types';
|
||||||
|
nav.partType = null;
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = nav.group.name;
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
// No part types? Skip directly to all parts in the group.
|
||||||
|
loadParts(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single part type? Skip the picker — go straight to parts.
|
||||||
|
if (data.data.length === 1) {
|
||||||
|
var only = data.data[0];
|
||||||
|
nav.partType = { slug: only.slug, name: only.name };
|
||||||
|
loadParts(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (pt) {
|
||||||
|
var img = pt.sample_image
|
||||||
|
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
|
||||||
|
: '';
|
||||||
|
return '<div class="nav-card" role="listitem" data-pt-slug="' + esc(pt.slug) + '" data-pt-name="' + esc(pt.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName };
|
||||||
loadParts(1);
|
loadParts(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadParts(page) {
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// NEXPART (Local mode) — parallel navigation functions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// These run in parallel to loadCategories / loadGroups / loadPartTypes
|
||||||
|
// and are only invoked when catalogMode === 'local'. They share the
|
||||||
|
// same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the
|
||||||
|
// Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup
|
||||||
|
// / nxPartType instead of nav.category / group / partType.
|
||||||
|
|
||||||
|
function loadCategoriesForMode() {
|
||||||
|
// Dispatcher — called by every place that used to call loadCategories()
|
||||||
|
if (catalogMode === 'local') {
|
||||||
|
loadNexpartCategories();
|
||||||
|
} else {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNexpartCategories() {
|
||||||
|
nav.level = 'categories';
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = 'Categorias (Local)';
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (c) {
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(c.slug) + '" data-name="' + esc(c.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
// Reset deeper Nexpart state so a re-click always goes to
|
||||||
|
// a clean subgroup list.
|
||||||
|
nav.nxSubgroup = null;
|
||||||
|
nav.nxPartType = null;
|
||||||
|
loadNexpartSubgroups();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNexpartSubgroups() {
|
||||||
|
nav.level = 'groups';
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = nav.nxGroup.name;
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye
|
||||||
|
+ '&category_slug=' + encodeURIComponent(nav.nxGroup.slug);
|
||||||
|
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (s) {
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
nav.nxPartType = null;
|
||||||
|
loadNexpartPartTypes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNexpartPartTypes() {
|
||||||
|
nav.level = 'part_types';
|
||||||
|
nav.nxPartType = null;
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = nav.nxSubgroup.name;
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye
|
||||||
|
+ '&group_slug=' + encodeURIComponent(nav.nxGroup.slug)
|
||||||
|
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
|
||||||
|
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single part type? Auto-drill-down to parts (UX shortcut).
|
||||||
|
if (data.data.length === 1) {
|
||||||
|
var only = data.data[0];
|
||||||
|
nav.nxPartType = { slug: only.slug, name: only.name };
|
||||||
|
loadParts(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (pt) {
|
||||||
|
var img = pt.sample_image
|
||||||
|
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
|
||||||
|
: '';
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
loadParts(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// SHOP SUPPLIES (Supplies mode) — vehicle-independent
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses
|
||||||
|
// the Nexpart slot because Supplies is a subset of the Nexpart taxonomy)
|
||||||
|
// but calls a different set of endpoints (/shop-supplies/*) that don't
|
||||||
|
// require an mye_id.
|
||||||
|
|
||||||
|
function loadShopSuppliesGroups() {
|
||||||
|
nav.level = 'categories';
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = 'Shop Supplies (sin vehiculo)';
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
apiFetch(API + '/shop-supplies/groups').then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (g) {
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(g.slug) + '" data-name="' + esc(g.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + g.subgroup_count + ' subgrupos</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
nav.nxSubgroup = null;
|
||||||
|
nav.nxPartType = null;
|
||||||
|
loadShopSuppliesSubgroups();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShopSuppliesSubgroups() {
|
||||||
|
nav.level = 'groups';
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = nav.nxGroup.name;
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug);
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (s) {
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
nav.nxPartType = null;
|
||||||
|
loadShopSuppliesPartTypes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShopSuppliesPartTypes() {
|
||||||
|
nav.level = 'part_types';
|
||||||
|
nav.nxPartType = null;
|
||||||
|
pushNavState();
|
||||||
|
updateBreadcrumb();
|
||||||
|
levelTitle.textContent = nav.nxSubgroup.name;
|
||||||
|
setupLevelFilter(true);
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
var url = API + '/shop-supplies/part-types'
|
||||||
|
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
|
||||||
|
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
|
||||||
|
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single part type? Skip the picker.
|
||||||
|
if (data.data.length === 1) {
|
||||||
|
var only = data.data[0];
|
||||||
|
nav.nxPartType = { slug: only.slug, name: only.name };
|
||||||
|
loadShopSuppliesParts(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navGrid.className = 'nav-grid';
|
||||||
|
navGrid.innerHTML = data.data.map(function (pt) {
|
||||||
|
var img = pt.sample_image
|
||||||
|
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
|
||||||
|
: '';
|
||||||
|
return '<div class="nav-card" role="listitem" ' +
|
||||||
|
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
|
||||||
|
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
|
||||||
|
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
|
||||||
|
loadShopSuppliesParts(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShopSuppliesParts(page) {
|
||||||
nav.level = 'parts';
|
nav.level = 'parts';
|
||||||
pushNavState();
|
pushNavState();
|
||||||
currentPage = page || 1;
|
currentPage = page || 1;
|
||||||
updateBreadcrumb();
|
updateBreadcrumb();
|
||||||
levelTitle.textContent = nav.group.name;
|
levelTitle.textContent = nav.nxPartType.name;
|
||||||
setupLevelFilter(false);
|
setupLevelFilter(false);
|
||||||
showLoading();
|
showLoading();
|
||||||
navGrid.innerHTML = '';
|
navGrid.innerHTML = '';
|
||||||
|
|
||||||
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
|
var url = API + '/shop-supplies/parts'
|
||||||
hideLoading();
|
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
|
||||||
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
|
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug)
|
||||||
|
+ '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug)
|
||||||
|
+ '&page=' + currentPage + '&per_page=30';
|
||||||
|
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) {
|
||||||
|
showEmpty('Sin partes', 'No hay partes en este tipo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the same aftermarket-styled rendering as Local mode.
|
||||||
partsGrid.style.display = '';
|
partsGrid.style.display = '';
|
||||||
partsGrid.innerHTML = data.data.map(function (p) {
|
partsGrid.innerHTML = data.data.map(function (p) {
|
||||||
var stockBadge;
|
var stockBadge;
|
||||||
if (p.local_stock > 0) {
|
if (p.in_stock_network || p.bodega_count > 0) {
|
||||||
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
|
|
||||||
} else if (p.bodega_count > 0) {
|
|
||||||
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
|
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
|
||||||
} else {
|
} else {
|
||||||
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
|
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
|
||||||
@@ -424,10 +842,123 @@
|
|||||||
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
||||||
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
|
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
|
||||||
|
|
||||||
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
|
var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : '');
|
||||||
|
var manuBadge = '';
|
||||||
|
if (p.manufacturer) {
|
||||||
|
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">★</span>' : '';
|
||||||
|
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
|
||||||
|
}
|
||||||
|
var skuLine = p.part_number
|
||||||
|
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number || '') + '</span>'
|
||||||
|
: esc(p.oem_part_number || '');
|
||||||
|
|
||||||
|
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
|
||||||
'<div class="part-card__image">' + imgHtml + '</div>' +
|
'<div class="part-card__image">' + imgHtml + '</div>' +
|
||||||
'<div class="part-card__body">' +
|
'<div class="part-card__body">' +
|
||||||
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
|
manuBadge +
|
||||||
|
'<div class="part-card__oem">' + skuLine + '</div>' +
|
||||||
|
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="part-card__footer">' +
|
||||||
|
'<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>' +
|
||||||
|
stockBadge +
|
||||||
|
'</div>' +
|
||||||
|
'</article>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
openPartDetail(parseInt(this.dataset.partId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.pagination) renderPagination(data.pagination);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParts(page) {
|
||||||
|
nav.level = 'parts';
|
||||||
|
pushNavState();
|
||||||
|
currentPage = page || 1;
|
||||||
|
updateBreadcrumb();
|
||||||
|
|
||||||
|
// Title: Nexpart part type > TecDoc part type > TecDoc group
|
||||||
|
if (nav.nxPartType) {
|
||||||
|
levelTitle.textContent = nav.nxPartType.name;
|
||||||
|
} else if (nav.partType) {
|
||||||
|
levelTitle.textContent = nav.partType.name;
|
||||||
|
} else if (nav.group) {
|
||||||
|
levelTitle.textContent = nav.group.name;
|
||||||
|
} else {
|
||||||
|
levelTitle.textContent = 'Partes';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLevelFilter(false);
|
||||||
|
showLoading();
|
||||||
|
navGrid.innerHTML = '';
|
||||||
|
|
||||||
|
// Build the URL based on which navigation branch the user took.
|
||||||
|
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
|
||||||
|
var url;
|
||||||
|
if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) {
|
||||||
|
url = API + '/parts?mode=local'
|
||||||
|
+ '&mye_id=' + nav.engine.id_mye
|
||||||
|
+ '&page=' + currentPage + '&per_page=30'
|
||||||
|
+ '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug)
|
||||||
|
+ '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug)
|
||||||
|
+ '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug);
|
||||||
|
} else {
|
||||||
|
var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : '';
|
||||||
|
url = API + '/parts?mye_id=' + nav.engine.id_mye
|
||||||
|
+ '&group_id=' + nav.group.id
|
||||||
|
+ '&page=' + currentPage + '&per_page=30'
|
||||||
|
+ '&mode=' + catalogMode
|
||||||
|
+ ptParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiFetch(url).then(function (data) {
|
||||||
|
hideLoading();
|
||||||
|
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
|
||||||
|
|
||||||
|
var isLocal = (catalogMode === 'local');
|
||||||
|
|
||||||
|
partsGrid.style.display = '';
|
||||||
|
partsGrid.innerHTML = data.data.map(function (p) {
|
||||||
|
// Stock badge — prefer tenant stock, then warehouse network, else fallback
|
||||||
|
var stockBadge;
|
||||||
|
if (p.local_stock > 0) {
|
||||||
|
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
|
||||||
|
} else if (p.in_stock_network || p.bodega_count > 0) {
|
||||||
|
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
|
||||||
|
} else {
|
||||||
|
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgHtml = p.image_url
|
||||||
|
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
||||||
|
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
|
||||||
|
|
||||||
|
// Local-mode extras: manufacturer badge + priority tier indicator
|
||||||
|
var manuBadge = '';
|
||||||
|
var tierClass = '';
|
||||||
|
if (isLocal && p.manufacturer) {
|
||||||
|
var tierLabel = '';
|
||||||
|
if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; }
|
||||||
|
else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; }
|
||||||
|
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' +
|
||||||
|
(tierLabel ? '<span class="manu-tier">' + tierLabel + '</span>' : '') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SKU to show: aftermarket part_number in local mode, OEM number otherwise
|
||||||
|
var skuLine = isLocal && p.part_number
|
||||||
|
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
|
||||||
|
: esc(p.oem_part_number);
|
||||||
|
|
||||||
|
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
|
||||||
|
'<div class="part-card__image">' + imgHtml + '</div>' +
|
||||||
|
'<div class="part-card__body">' +
|
||||||
|
manuBadge +
|
||||||
|
'<div class="part-card__oem">' + skuLine + '</div>' +
|
||||||
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="part-card__footer">' +
|
'<div class="part-card__footer">' +
|
||||||
@@ -618,11 +1149,148 @@
|
|||||||
// ─── SMART SEARCH ───
|
// ─── SMART SEARCH ───
|
||||||
var searchTimeout = null;
|
var searchTimeout = null;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// SMART SEARCH — auto-detect VIN / plate / part number / keyword
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Returns: 'vin' | 'plate' | 'part_number' | 'keyword'
|
||||||
|
|
||||||
|
function detectQueryType(raw) {
|
||||||
|
if (!raw) return 'keyword';
|
||||||
|
var q = raw.trim();
|
||||||
|
|
||||||
|
// Strip common separators for detection (VINs/parts rarely contain spaces)
|
||||||
|
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
|
||||||
|
|
||||||
|
// VIN: exactly 17 chars alphanumeric, no I/O/Q
|
||||||
|
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
|
||||||
|
|
||||||
|
// Mexican license plate: 3 letters + 3-4 digits (with/without hyphen)
|
||||||
|
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
|
||||||
|
|
||||||
|
// Part-number heuristic. Rules designed to avoid false positives on
|
||||||
|
// natural-language Spanish/English queries:
|
||||||
|
// 1. Original query must NOT contain lowercase letters. Real part
|
||||||
|
// numbers are always uppercase ("4G0-857-951-A"); keywords aren't.
|
||||||
|
// 2. No natural-language words allowed (para, de, con, for, the, etc.)
|
||||||
|
// 3. Either has a dash/slash separator, or is a solid alphanumeric blob.
|
||||||
|
var hasLowercase = /[a-z]/.test(q);
|
||||||
|
if (hasLowercase) return 'keyword';
|
||||||
|
|
||||||
|
// Block queries that contain a year-like 4-digit number alongside
|
||||||
|
// other tokens — those are "PART 2018" style vehicle refs, not parts.
|
||||||
|
var tokens = q.split(/\s+/);
|
||||||
|
var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); });
|
||||||
|
if (hasYear && tokens.length > 1) return 'keyword';
|
||||||
|
|
||||||
|
var qUpper = q.toUpperCase();
|
||||||
|
// Dashed/slashed part number: "4G0-857-951-A", "BP-1234"
|
||||||
|
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) {
|
||||||
|
return 'part_number';
|
||||||
|
}
|
||||||
|
// Space-separated part number (rare but real, e.g. BOSCH "0 986 4B7 013")
|
||||||
|
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) {
|
||||||
|
return 'part_number';
|
||||||
|
}
|
||||||
|
// Solid alphanumeric blob 8+ chars with both letters+digits
|
||||||
|
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) {
|
||||||
|
return 'part_number';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'keyword';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint badge shown next to the search input. Injected lazily so we don't
|
||||||
|
// need to touch the HTML.
|
||||||
|
var searchHint = null;
|
||||||
|
function ensureSearchHint() {
|
||||||
|
if (searchHint) return searchHint;
|
||||||
|
searchHint = document.createElement('div');
|
||||||
|
searchHint.id = 'searchHint';
|
||||||
|
searchHint.style.cssText =
|
||||||
|
'position:absolute;top:100%;left:0;margin-top:4px;padding:3px 10px;' +
|
||||||
|
'background:var(--color-primary-muted);color:var(--color-text-accent);' +
|
||||||
|
'font-size:var(--text-caption);font-weight:var(--font-weight-semibold);' +
|
||||||
|
'border:1px dashed var(--color-border-accent);border-radius:var(--radius-sm);' +
|
||||||
|
'white-space:nowrap;pointer-events:none;z-index:10;display:none;';
|
||||||
|
searchInput.parentElement.appendChild(searchHint);
|
||||||
|
return searchHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchHint(type) {
|
||||||
|
var hint = ensureSearchHint();
|
||||||
|
var labels = {
|
||||||
|
vin: '🚗 VIN detectado — decodificando',
|
||||||
|
plate: '🔖 Placa detectada — consultando registro',
|
||||||
|
part_number: '🔩 Numero de parte detectado',
|
||||||
|
keyword: null,
|
||||||
|
};
|
||||||
|
var label = labels[type];
|
||||||
|
if (!label) {
|
||||||
|
hint.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
hint.textContent = label;
|
||||||
|
hint.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart dispatcher — decides which endpoint to call based on input type.
|
||||||
|
function runSmartSearch(q) {
|
||||||
|
var type = detectQueryType(q);
|
||||||
|
|
||||||
|
if (type === 'vin') {
|
||||||
|
// Use the existing VIN decoder flow
|
||||||
|
try { decodeVinWithValue(q); } catch (e) { runSearch(q); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'plate') {
|
||||||
|
// Use the existing plate lookup flow — assume default state MX
|
||||||
|
try { lookupPlateWithValue(q); } catch (e) { runSearch(q); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For part_number and keyword, both go through the existing /search
|
||||||
|
// endpoint (which supports full-text + OEM number search).
|
||||||
|
runSearch(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin wrappers around existing VIN/plate handlers — they usually read
|
||||||
|
// from their own input fields; these set the field and trigger.
|
||||||
|
function decodeVinWithValue(vin) {
|
||||||
|
var vinInput = document.getElementById('vinInput');
|
||||||
|
if (vinInput) {
|
||||||
|
vinInput.value = vin;
|
||||||
|
if (typeof decodeVin === 'function') decodeVin();
|
||||||
|
else runSearch(vin);
|
||||||
|
} else {
|
||||||
|
runSearch(vin); // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupPlateWithValue(plate) {
|
||||||
|
var plateInput = document.getElementById('plateInput');
|
||||||
|
if (plateInput) {
|
||||||
|
plateInput.value = plate.toUpperCase();
|
||||||
|
if (typeof lookupPlate === 'function') lookupPlate();
|
||||||
|
else runSearch(plate);
|
||||||
|
} else {
|
||||||
|
runSearch(plate); // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
searchInput.addEventListener('input', function () {
|
searchInput.addEventListener('input', function () {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
var q = this.value.trim();
|
var q = this.value.trim();
|
||||||
|
// Live type detection for the hint (runs on every keystroke)
|
||||||
|
updateSearchHint(q.length >= 3 ? detectQueryType(q) : null);
|
||||||
|
|
||||||
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
|
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
|
||||||
|
// For keyword queries, keep the debounced dropdown preview.
|
||||||
|
// For VIN/plate/part-number, wait for Enter — they're one-shot lookups.
|
||||||
|
var type = detectQueryType(q);
|
||||||
|
if (type === 'keyword') {
|
||||||
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
|
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('keydown', function (e) {
|
searchInput.addEventListener('keydown', function (e) {
|
||||||
@@ -630,10 +1298,11 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
var q = this.value.trim();
|
var q = this.value.trim();
|
||||||
if (q.length >= 2) runSearch(q);
|
if (q.length >= 2) runSmartSearch(q);
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
searchDropdown.classList.remove('is-visible');
|
searchDropdown.classList.remove('is-visible');
|
||||||
|
updateSearchHint(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -906,7 +1575,7 @@
|
|||||||
|
|
||||||
// Load brands filtered by year
|
// Load brands filtered by year
|
||||||
vsBrand.disabled = false;
|
vsBrand.disabled = false;
|
||||||
apiFetch(API + '/brands?year_id=' + yearId).then(function (data) {
|
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
|
||||||
var brands = data.data || data;
|
var brands = data.data || data;
|
||||||
if (!brands) return;
|
if (!brands) return;
|
||||||
vsBrand.innerHTML = '<option value="">Marca...</option>';
|
vsBrand.innerHTML = '<option value="">Marca...</option>';
|
||||||
@@ -980,7 +1649,7 @@
|
|||||||
nav.level = 'categories';
|
nav.level = 'categories';
|
||||||
pushNavState();
|
pushNavState();
|
||||||
|
|
||||||
loadCategories();
|
loadCategoriesForMode();
|
||||||
|
|
||||||
// Scroll to catalog content
|
// Scroll to catalog content
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
@@ -999,7 +1668,9 @@
|
|||||||
vsEngine.disabled = true;
|
vsEngine.disabled = true;
|
||||||
vsClear.style.display = 'none';
|
vsClear.style.display = 'none';
|
||||||
|
|
||||||
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; currentPage = 1;
|
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; nav.partType = null;
|
||||||
|
nav.nxGroup = null; nav.nxSubgroup = null; nav.nxPartType = null;
|
||||||
|
currentPage = 1;
|
||||||
pushNavState();
|
pushNavState();
|
||||||
loadBrands();
|
loadBrands();
|
||||||
}
|
}
|
||||||
@@ -1231,10 +1902,12 @@
|
|||||||
decodeVin: decodeVin,
|
decodeVin: decodeVin,
|
||||||
togglePlate: togglePlate,
|
togglePlate: togglePlate,
|
||||||
lookupPlate: lookupPlate,
|
lookupPlate: lookupPlate,
|
||||||
|
setMode: setCatalogMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── INIT ───
|
// ─── INIT ───
|
||||||
renderCart();
|
renderCart();
|
||||||
|
updateModeToggleUI();
|
||||||
vsLoadYears();
|
vsLoadYears();
|
||||||
loadBrands();
|
loadBrands();
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ const Config = (() => {
|
|||||||
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
|
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
|
||||||
+ '<td>' + statusBadge + '</td>'
|
+ '<td>' + statusBadge + '</td>'
|
||||||
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
|
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
|
||||||
+ '<td><button class="btn btn--ghost btn--sm" disabled>Editar</button></td>'
|
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,8 +265,21 @@ const Config = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEmployee(data) {
|
async function saveEmployee(data) {
|
||||||
var res = await fetch(API + '/employees', {
|
// Check if we're editing (modal has editId) or creating
|
||||||
method: 'POST',
|
var modal = document.getElementById('employee-modal');
|
||||||
|
var editId = modal ? modal.dataset.editId : null;
|
||||||
|
var url = API + '/employees';
|
||||||
|
var method = 'POST';
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
url = API + '/employees/' + editId;
|
||||||
|
method = 'PUT';
|
||||||
|
// Clear the edit marker so next use is a fresh create
|
||||||
|
delete modal.dataset.editId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await fetch(url, {
|
||||||
|
method: method,
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
@@ -302,6 +315,95 @@ const Config = (() => {
|
|||||||
if (el) el.value = v || '';
|
if (el) el.value = v || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVal(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
return el ? el.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editEmployee(empId) {
|
||||||
|
if (!checkAuth()) return;
|
||||||
|
// Find the employee in the loaded data by re-fetching
|
||||||
|
try {
|
||||||
|
var res = await fetch(API + '/employees', { headers: headers() });
|
||||||
|
if (!res.ok) throw new Error('Failed to load employees');
|
||||||
|
var json = await res.json();
|
||||||
|
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
||||||
|
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
||||||
|
|
||||||
|
// Pre-fill the "new employee" modal with existing data for editing
|
||||||
|
setVal('new-emp-name', emp.name);
|
||||||
|
setVal('new-emp-email', emp.email || '');
|
||||||
|
var roleSelect = document.getElementById('new-emp-role');
|
||||||
|
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
||||||
|
var branchSelect = document.getElementById('new-emp-branch');
|
||||||
|
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
||||||
|
setVal('new-emp-discount', emp.max_discount_pct || '');
|
||||||
|
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
|
||||||
|
|
||||||
|
// Store the ID so saveEmployee knows it's an update
|
||||||
|
var modal = document.getElementById('employee-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.dataset.editId = empId;
|
||||||
|
var title = modal.querySelector('.modal-title, h3');
|
||||||
|
if (title) title.textContent = 'Editar Empleado';
|
||||||
|
}
|
||||||
|
openModal('employee-modal');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTaxParams() {
|
||||||
|
if (!checkAuth()) return;
|
||||||
|
var data = {
|
||||||
|
tax_iva: getVal('tax-iva') || '16',
|
||||||
|
tax_ieps: getVal('tax-ieps') || '0',
|
||||||
|
invoice_serie: getVal('tax-serie') || 'FA',
|
||||||
|
invoice_folio: getVal('tax-folio') || '1',
|
||||||
|
default_currency: document.getElementById('tax-moneda') ? document.getElementById('tax-moneda').value : 'MXN',
|
||||||
|
default_payment_method: document.getElementById('tax-forma-pago') ? document.getElementById('tax-forma-pago').value : '01',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// Use the business PUT endpoint with tax_ prefixed keys
|
||||||
|
var res = await fetch(API + '/business', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Error al guardar');
|
||||||
|
toast('Parámetros de impuestos guardados', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBusiness() {
|
||||||
|
if (!checkAuth()) return;
|
||||||
|
var data = {
|
||||||
|
razon_social: getVal('biz-razon-social'),
|
||||||
|
nombre: getVal('biz-nombre'),
|
||||||
|
rfc: getVal('biz-rfc'),
|
||||||
|
regimen_fiscal: getVal('biz-regimen'),
|
||||||
|
direccion: getVal('biz-direccion'),
|
||||||
|
telefono: getVal('biz-telefono'),
|
||||||
|
email: getVal('biz-email'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
var res = await fetch(API + '/business', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||||
|
throw new Error(err.error || 'Error al guardar');
|
||||||
|
}
|
||||||
|
toast('Datos de empresa guardados', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Event bindings
|
// Event bindings
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -525,7 +627,8 @@ const Config = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
init, setTheme, selectThemeOption,
|
init, setTheme, selectThemeOption,
|
||||||
loadBranches, loadEmployees, saveBranch, saveEmployee,
|
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||||
|
loadBusiness, saveBusiness, saveTaxParams,
|
||||||
loadCurrency, saveCurrency,
|
loadCurrency, saveCurrency,
|
||||||
openModal, closeModal
|
openModal, closeModal
|
||||||
};
|
};
|
||||||
|
|||||||
404
pos/static/js/pos-utils.js
Normal file
404
pos/static/js/pos-utils.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* pos-utils.js — Shared utility functions for all POS pages.
|
||||||
|
*
|
||||||
|
* Provides common operations that multiple pages need:
|
||||||
|
* - CSV export of any visible table
|
||||||
|
* - Print page (PDF via browser print dialog)
|
||||||
|
* - Toast notifications (if page doesn't have its own)
|
||||||
|
*
|
||||||
|
* Load this script in every POS template BEFORE page-specific JS.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── CSV Export ──────────────────────────────────────────────────
|
||||||
|
// Finds the first visible <table> on the page and downloads it as CSV.
|
||||||
|
// Works on inventory, customers, invoicing, reports, accounting.
|
||||||
|
|
||||||
|
window.exportVisibleTableCSV = function(prefix) {
|
||||||
|
prefix = prefix || 'datos';
|
||||||
|
var tables = document.querySelectorAll('table');
|
||||||
|
var table = null;
|
||||||
|
|
||||||
|
// Find first visible table with data rows
|
||||||
|
for (var i = 0; i < tables.length; i++) {
|
||||||
|
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
|
||||||
|
table = tables[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
showToast('No hay tabla de datos para exportar en esta vista.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = [];
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
var ths = table.querySelectorAll('thead th');
|
||||||
|
if (ths.length) {
|
||||||
|
rows.push(Array.from(ths).map(function(th) {
|
||||||
|
return '"' + th.textContent.trim().replace(/"/g, '""') + '"';
|
||||||
|
}).join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||||
|
var cells = tr.querySelectorAll('td');
|
||||||
|
rows.push(Array.from(cells).map(function(td) {
|
||||||
|
return '"' + td.textContent.trim().replace(/"/g, '""') + '"';
|
||||||
|
}).join(','));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length <= 1) {
|
||||||
|
showToast('La tabla está vacía — no hay datos para exportar.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var csv = rows.join('\n');
|
||||||
|
// BOM prefix so Excel opens UTF-8 correctly
|
||||||
|
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('CSV descargado: ' + a.download, 'ok');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Print (PDF) ────────────────────────────────────────────────
|
||||||
|
window.printPage = function() {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Toast (simple, non-blocking notification) ──────────────────
|
||||||
|
// Only creates its own toast if the page doesn't already have one.
|
||||||
|
window.showToast = function(msg, type) {
|
||||||
|
type = type || 'info';
|
||||||
|
var container = document.getElementById('toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'toast-container';
|
||||||
|
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
var colors = {
|
||||||
|
ok: 'background:#1a7a3a;color:#fff;',
|
||||||
|
error: 'background:#c0392b;color:#fff;',
|
||||||
|
warn: 'background:#d4a017;color:#000;',
|
||||||
|
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
|
||||||
|
};
|
||||||
|
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.style.cssText = (colors[type] || colors.info) +
|
||||||
|
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
|
||||||
|
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
|
||||||
|
'animation:slideInRight 0.3s ease;max-width:400px;';
|
||||||
|
toast.textContent = msg;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity 0.3s';
|
||||||
|
setTimeout(function() { toast.remove(); }, 300);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── "Próximamente" placeholder for features not yet built ──────
|
||||||
|
window.featureProximamente = function(nombre) {
|
||||||
|
showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Table Filter Panel ────────────────────────────────────────
|
||||||
|
// Creates a dropdown filter panel that filters visible table rows
|
||||||
|
// client-side. Call toggleFilterPanel(buttonEl, config) where config
|
||||||
|
// is an array of {label, column, values} describing each filter.
|
||||||
|
//
|
||||||
|
// Usage (from onclick):
|
||||||
|
// toggleFilterPanel(this, [
|
||||||
|
// {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']},
|
||||||
|
// {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']},
|
||||||
|
// ])
|
||||||
|
|
||||||
|
var _activeFilterPanel = null;
|
||||||
|
|
||||||
|
window.toggleFilterPanel = function(btnEl, filters) {
|
||||||
|
// Close existing panel if open
|
||||||
|
if (_activeFilterPanel) {
|
||||||
|
_activeFilterPanel.remove();
|
||||||
|
_activeFilterPanel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'filter-panel';
|
||||||
|
panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' +
|
||||||
|
'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' +
|
||||||
|
'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' +
|
||||||
|
'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' +
|
||||||
|
'display:flex;flex-direction:column;gap:12px;';
|
||||||
|
|
||||||
|
var title = document.createElement('div');
|
||||||
|
title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;';
|
||||||
|
title.innerHTML = 'Filtros <button onclick="closeFilterPanel()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:18px;">✕</button>';
|
||||||
|
panel.appendChild(title);
|
||||||
|
|
||||||
|
filters.forEach(function(f) {
|
||||||
|
var group = document.createElement('div');
|
||||||
|
var label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;';
|
||||||
|
label.textContent = f.label;
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
|
var select = document.createElement('select');
|
||||||
|
select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' +
|
||||||
|
'border:1px solid var(--glass-border,#444);border-radius:6px;' +
|
||||||
|
'color:var(--color-text-primary,#fff);font-size:13px;';
|
||||||
|
select.dataset.filterColumn = f.column;
|
||||||
|
|
||||||
|
// "Todos" option always first
|
||||||
|
var allOpt = document.createElement('option');
|
||||||
|
allOpt.value = '';
|
||||||
|
allOpt.textContent = f.allLabel || 'Todos';
|
||||||
|
select.appendChild(allOpt);
|
||||||
|
|
||||||
|
(f.values || []).forEach(function(v) {
|
||||||
|
if (!v) return;
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = v;
|
||||||
|
opt.textContent = v;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', function() { applyFilters(panel); });
|
||||||
|
group.appendChild(select);
|
||||||
|
panel.appendChild(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all button
|
||||||
|
var clearBtn = document.createElement('button');
|
||||||
|
clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' +
|
||||||
|
'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;';
|
||||||
|
clearBtn.textContent = 'Limpiar filtros';
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
panel.querySelectorAll('select').forEach(function(s) { s.value = ''; });
|
||||||
|
applyFilters(panel);
|
||||||
|
});
|
||||||
|
panel.appendChild(clearBtn);
|
||||||
|
|
||||||
|
// Position relative to the button
|
||||||
|
var wrapper = btnEl.parentElement;
|
||||||
|
if (wrapper) wrapper.style.position = 'relative';
|
||||||
|
(wrapper || document.body).appendChild(panel);
|
||||||
|
_activeFilterPanel = panel;
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
setTimeout(function() {
|
||||||
|
document.addEventListener('click', function handler(e) {
|
||||||
|
if (!panel.contains(e.target) && e.target !== btnEl) {
|
||||||
|
closeFilterPanel();
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeFilterPanel = function() {
|
||||||
|
if (_activeFilterPanel) {
|
||||||
|
_activeFilterPanel.remove();
|
||||||
|
_activeFilterPanel = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyFilters(panel) {
|
||||||
|
var selects = panel.querySelectorAll('select[data-filter-column]');
|
||||||
|
// Find the nearest visible table
|
||||||
|
var tables = document.querySelectorAll('table');
|
||||||
|
var table = null;
|
||||||
|
for (var i = 0; i < tables.length; i++) {
|
||||||
|
if (tables[i].offsetParent !== null) { table = tables[i]; break; }
|
||||||
|
}
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
var rows = table.querySelectorAll('tbody tr');
|
||||||
|
rows.forEach(function(tr) {
|
||||||
|
var show = true;
|
||||||
|
selects.forEach(function(sel) {
|
||||||
|
var col = parseInt(sel.dataset.filterColumn);
|
||||||
|
var val = sel.value.toLowerCase();
|
||||||
|
if (!val) return; // "Todos" — no filter
|
||||||
|
var cells = tr.querySelectorAll('td');
|
||||||
|
if (cells[col]) {
|
||||||
|
var cellText = cells[col].textContent.trim().toLowerCase();
|
||||||
|
if (cellText.indexOf(val.toLowerCase()) === -1) show = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tr.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update count badge if exists
|
||||||
|
var visibleCount = 0;
|
||||||
|
rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; });
|
||||||
|
var badge = document.querySelector('.filter-count-badge');
|
||||||
|
if (badge) badge.textContent = visibleCount + ' resultados';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-extract unique values from a table column ──────────
|
||||||
|
// Useful for building filter options dynamically from data.
|
||||||
|
window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) {
|
||||||
|
maxValues = maxValues || 30;
|
||||||
|
var values = {};
|
||||||
|
if (!tableEl) return [];
|
||||||
|
tableEl.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||||
|
var cells = tr.querySelectorAll('td');
|
||||||
|
if (cells[colIndex]) {
|
||||||
|
var v = cells[colIndex].textContent.trim();
|
||||||
|
if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sort by frequency (most common first)
|
||||||
|
return Object.keys(values)
|
||||||
|
.sort(function(a, b) { return values[b] - values[a]; })
|
||||||
|
.slice(0, maxValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Auto-print polling for WhatsApp quotations ───────────────
|
||||||
|
// Polls /quotations/print-queue every 15s. When a confirmed WA quote
|
||||||
|
// is found, it fetches the ESC/POS bytes and sends to the connected
|
||||||
|
// thermal printer. Falls back to browser print if no thermal is connected.
|
||||||
|
|
||||||
|
var _autoPrintTimer = null;
|
||||||
|
var _autoPrintEnabled = false;
|
||||||
|
|
||||||
|
window.startAutoPrint = function() {
|
||||||
|
if (_autoPrintTimer) return;
|
||||||
|
_autoPrintEnabled = true;
|
||||||
|
var token = localStorage.getItem('pos_token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
_autoPrintTimer = setInterval(function() {
|
||||||
|
fetch('/pos/api/quotations/print-queue', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (!d.data || !d.data.length) return;
|
||||||
|
d.data.forEach(function(q) {
|
||||||
|
console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...');
|
||||||
|
showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok');
|
||||||
|
autoPrintQuote(q.id, token);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function() {}); // silent on errors
|
||||||
|
}, 15000); // every 15 seconds
|
||||||
|
|
||||||
|
console.log('[auto-print] Enabled — polling every 15s');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.stopAutoPrint = function() {
|
||||||
|
if (_autoPrintTimer) {
|
||||||
|
clearInterval(_autoPrintTimer);
|
||||||
|
_autoPrintTimer = null;
|
||||||
|
}
|
||||||
|
_autoPrintEnabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function autoPrintQuote(quoteId, token) {
|
||||||
|
// Try thermal printer first (via NexusPrinter if loaded)
|
||||||
|
if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) {
|
||||||
|
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.arrayBuffer(); })
|
||||||
|
.then(function(buf) {
|
||||||
|
NexusPrinter.sendRaw(new Uint8Array(buf));
|
||||||
|
markPrinted(quoteId, token);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
console.error('[auto-print] Thermal print failed:', e);
|
||||||
|
browserPrintQuote(quoteId, token);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
browserPrintQuote(quoteId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserPrintQuote(quoteId, token) {
|
||||||
|
// Fallback: open a print-friendly window
|
||||||
|
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ printer_type: 'browser' }),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(q) {
|
||||||
|
var html = '<html><head><title>Cotización #' + q.id + '</title>';
|
||||||
|
html += '<style>body{font-family:monospace;font-size:12px;width:80mm;margin:0 auto;padding:10px;}';
|
||||||
|
html += 'h1{font-size:18px;text-align:center;margin:0;}';
|
||||||
|
html += '.center{text-align:center;}.right{text-align:right;}';
|
||||||
|
html += 'hr{border:none;border-top:1px dashed #000;}';
|
||||||
|
html += 'table{width:100%;border-collapse:collapse;}td{padding:2px 4px;}</style></head><body>';
|
||||||
|
html += '<h1>COTIZACIÓN</h1>';
|
||||||
|
html += '<p class="center">COT-' + q.id + '</p>';
|
||||||
|
html += '<p>Fecha: ' + (q.created_at || '').substring(0, 10) + '</p>';
|
||||||
|
if (q.customer_name) html += '<p>Cliente: ' + q.customer_name + '</p>';
|
||||||
|
if (q.wa_phone) html += '<p>WhatsApp: ' + q.wa_phone + '</p>';
|
||||||
|
html += '<hr><table>';
|
||||||
|
(q.items || []).forEach(function(it) {
|
||||||
|
html += '<tr><td>' + it.quantity + 'x ' + it.name + '</td><td class="right">$' + it.subtotal.toFixed(2) + '</td></tr>';
|
||||||
|
if (it.part_number) html += '<tr><td colspan="2" style="font-size:10px;color:#666;"> #' + it.part_number + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</table><hr>';
|
||||||
|
html += '<p class="right">Subtotal: $' + q.subtotal.toFixed(2) + '</p>';
|
||||||
|
html += '<p class="right">IVA: $' + q.tax_total.toFixed(2) + '</p>';
|
||||||
|
html += '<p class="right" style="font-size:16px;font-weight:bold;">TOTAL: $' + q.total.toFixed(2) + '</p>';
|
||||||
|
html += '<hr><p class="center" style="font-size:10px;">Esta cotización no es comprobante fiscal<br>Precios sujetos a disponibilidad</p>';
|
||||||
|
html += '</body></html>';
|
||||||
|
|
||||||
|
var w = window.open('', '_blank', 'width=400,height=600');
|
||||||
|
w.document.write(html);
|
||||||
|
w.document.close();
|
||||||
|
setTimeout(function() { w.print(); }, 500);
|
||||||
|
markPrinted(quoteId, token);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
console.error('[auto-print] Browser print failed:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPrinted(quoteId, token) {
|
||||||
|
fetch('/pos/api/quotations/' + quoteId + '/mark-printed', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start polling on pages that are likely to have a printer
|
||||||
|
// (POS sale page and quotations page)
|
||||||
|
if (window.location.pathname.indexOf('/pos/sale') !== -1 ||
|
||||||
|
window.location.pathname.indexOf('/pos/quotation') !== -1 ||
|
||||||
|
window.location.pathname.indexOf('/pos/dashboard') !== -1) {
|
||||||
|
var _initToken = localStorage.getItem('pos_token');
|
||||||
|
if (_initToken) {
|
||||||
|
setTimeout(function() { startAutoPrint(); }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject styles
|
||||||
|
if (!document.getElementById('pos-utils-styles')) {
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = 'pos-utils-styles';
|
||||||
|
style.textContent = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}' +
|
||||||
|
'.filter-panel select:focus{outline:none;border-color:var(--color-primary,#F5A623);box-shadow:0 0 0 2px var(--glow-color-soft,rgba(245,166,35,0.15));}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -715,3 +715,39 @@ const Reports = (() => {
|
|||||||
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
|
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Global: Export visible table as CSV (Excel-compatible) ──
|
||||||
|
function exportReportCSV() {
|
||||||
|
var tables = document.querySelectorAll('table');
|
||||||
|
// Find the first visible table
|
||||||
|
var table = null;
|
||||||
|
for (var i = 0; i < tables.length; i++) {
|
||||||
|
var t = tables[i];
|
||||||
|
if (t.offsetParent !== null && t.querySelector('tbody tr')) {
|
||||||
|
table = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!table) {
|
||||||
|
alert('No hay tabla de datos para exportar en esta vista.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = [];
|
||||||
|
var ths = table.querySelectorAll('thead th');
|
||||||
|
if (ths.length) {
|
||||||
|
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||||
|
}
|
||||||
|
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||||
|
var cells = tr.querySelectorAll('td');
|
||||||
|
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||||
|
});
|
||||||
|
if (rows.length <= 1) { alert('La tabla esta vacia.'); return; }
|
||||||
|
var csv = rows.join('\n');
|
||||||
|
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'reporte_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
]},
|
]},
|
||||||
{ label: _t('nav_management'), items: [
|
{ label: _t('nav_management'), items: [
|
||||||
{ 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"/>' },
|
{ 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"/>' },
|
||||||
|
{ 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"/>' },
|
||||||
|
{ 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"/>' },
|
||||||
{ 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"/>' },
|
{ 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"/>' },
|
||||||
{ 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"/>' },
|
{ 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"/>' },
|
||||||
{ 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"/>' },
|
{ 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"/>' },
|
||||||
@@ -163,4 +165,61 @@
|
|||||||
var main = document.querySelector('main, .main-content, #mainContent, .main, .page-content');
|
var main = document.querySelector('main, .main-content, #mainContent, .main, .page-content');
|
||||||
if (main) main.classList.add('pos-main-offset');
|
if (main) main.classList.add('pos-main-offset');
|
||||||
|
|
||||||
|
// ── Tablet/mobile: sidebar toggle + overlay ─────────────────────
|
||||||
|
// Creates a hamburger button + overlay for screens < 1024px.
|
||||||
|
// The CSS in pos-glass.css hides the sidebar by default on tablets
|
||||||
|
// and shows it as a slide-in drawer when .open is added.
|
||||||
|
|
||||||
|
var sidebar = document.querySelector('.pos-sidebar, .sidebar, #sidebar');
|
||||||
|
var overlay = document.getElementById('sidebar-overlay');
|
||||||
|
|
||||||
|
// Create overlay if it doesn't exist
|
||||||
|
if (!overlay && sidebar) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'sidebar-overlay';
|
||||||
|
overlay.className = 'sidebar-overlay';
|
||||||
|
overlay.addEventListener('click', function () { closeSidebar(); });
|
||||||
|
sidebar.parentNode.insertBefore(overlay, sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hamburger button if it doesn't exist
|
||||||
|
var hamburger = document.getElementById('hamburger-btn');
|
||||||
|
if (!hamburger) {
|
||||||
|
hamburger = document.createElement('button');
|
||||||
|
hamburger.id = 'hamburger-btn';
|
||||||
|
hamburger.className = 'hamburger-btn';
|
||||||
|
hamburger.setAttribute('aria-label', 'Menú');
|
||||||
|
hamburger.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
||||||
|
hamburger.style.cssText = 'display:none;position:fixed;top:10px;left:10px;z-index:' +
|
||||||
|
(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-modal') || 1050) + 2) +
|
||||||
|
';background:var(--glass-bg-strong);backdrop-filter:blur(12px);border:1px solid var(--glass-border);' +
|
||||||
|
'border-radius:var(--radius-md);padding:8px;cursor:pointer;color:var(--color-text-primary);' +
|
||||||
|
'box-shadow:0 2px 8px rgba(0,0,0,0.2);';
|
||||||
|
hamburger.addEventListener('click', function () { toggleSidebar(); });
|
||||||
|
document.body.appendChild(hamburger);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
if (!sidebar) return;
|
||||||
|
var isOpen = sidebar.classList.contains('open');
|
||||||
|
sidebar.classList.toggle('open', !isOpen);
|
||||||
|
if (overlay) overlay.classList.toggle('open', !isOpen);
|
||||||
|
document.body.style.overflow = isOpen ? '' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
if (sidebar) sidebar.classList.remove('open');
|
||||||
|
if (overlay) overlay.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close sidebar on window resize to desktop
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
if (window.innerWidth >= 1024) closeSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose globally so inline onclick handlers and page-specific JS can call them
|
||||||
|
window.toggleSidebar = toggleSidebar;
|
||||||
|
window.closeSidebar = closeSidebar;
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -103,6 +103,9 @@
|
|||||||
messengerArea.style.display = 'flex';
|
messengerArea.style.display = 'flex';
|
||||||
disconnectBtn.style.display = '';
|
disconnectBtn.style.display = '';
|
||||||
connectBtn.style.display = 'none';
|
connectBtn.style.display = 'none';
|
||||||
|
// Load conversations + start polling on page load / reconnect
|
||||||
|
loadConversations();
|
||||||
|
startPolling();
|
||||||
} else if (state === 'connecting') {
|
} else if (state === 'connecting') {
|
||||||
statusDot.className = 'status-dot status-dot--warn';
|
statusDot.className = 'status-dot status-dot--warn';
|
||||||
statusText.textContent = 'Escaneando QR...';
|
statusText.textContent = 'Escaneando QR...';
|
||||||
@@ -221,18 +224,43 @@
|
|||||||
var html = '';
|
var html = '';
|
||||||
convs.forEach(function (c) {
|
convs.forEach(function (c) {
|
||||||
var isActive = c.phone === activePhone;
|
var isActive = c.phone === activePhone;
|
||||||
var dirIcon = c.last_direction === 'outgoing' ? '→ ' : '';
|
var dirIcon = c.last_direction === 'outgoing' ? '↗ ' : '↙ ';
|
||||||
|
// Show contact name if available, otherwise try to format the phone.
|
||||||
|
// LID numbers (15+ digits, no country code pattern) show as "Contacto"
|
||||||
|
var displayName = c.contact_name || '';
|
||||||
|
if (!displayName) {
|
||||||
|
var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone);
|
||||||
|
displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone);
|
||||||
|
}
|
||||||
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
||||||
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
|
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
|
||||||
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
|
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
|
||||||
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
||||||
|
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">×</button>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
});
|
});
|
||||||
|
// "Borrar todo" button at the bottom
|
||||||
|
html += '<div style="padding:8px;text-align:center;">'
|
||||||
|
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
|
||||||
|
+ '</div>';
|
||||||
convList.innerHTML = html;
|
convList.innerHTML = html;
|
||||||
|
|
||||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||||
el.addEventListener('click', function () {
|
el.addEventListener('click', function (e) {
|
||||||
openConversation(el.getAttribute('data-phone'));
|
if (e.target.classList.contains('conv-item__delete')) return;
|
||||||
|
var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : '';
|
||||||
|
openConversation(el.getAttribute('data-phone'), name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire delete buttons
|
||||||
|
convList.querySelectorAll('.conv-item__delete').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var phone = btn.getAttribute('data-del-phone');
|
||||||
|
if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) {
|
||||||
|
deleteConversation(phone);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
@@ -240,11 +268,43 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteConversation(phone) {
|
||||||
|
api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
if (activePhone === phone) {
|
||||||
|
activePhone = null;
|
||||||
|
chatPanel.style.display = 'none';
|
||||||
|
emptyState.style.display = '';
|
||||||
|
}
|
||||||
|
loadConversations();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (res.error || 'unknown'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.deleteAllConversations = function () {
|
||||||
|
if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return;
|
||||||
|
api('DELETE', '/conversations').then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
activePhone = null;
|
||||||
|
chatPanel.style.display = 'none';
|
||||||
|
emptyState.style.display = '';
|
||||||
|
loadConversations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// -- Open a conversation ---------------------------------------------------
|
// -- Open a conversation ---------------------------------------------------
|
||||||
|
|
||||||
function openConversation(phone) {
|
var activeContactName = '';
|
||||||
|
|
||||||
|
function openConversation(phone, contactName) {
|
||||||
activePhone = phone;
|
activePhone = phone;
|
||||||
chatHeader.textContent = fmtPhone(phone);
|
// Use contact name if available; fall back to formatted phone
|
||||||
|
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
|
||||||
|
activeContactName = contactName || '';
|
||||||
|
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
chatPanel.style.display = 'flex';
|
chatPanel.style.display = 'flex';
|
||||||
|
|
||||||
@@ -267,13 +327,13 @@
|
|||||||
var html = '';
|
var html = '';
|
||||||
msgs.forEach(function (m) {
|
msgs.forEach(function (m) {
|
||||||
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
||||||
var statusBadge = '';
|
// Support both 'text' and 'message_text' keys (backend changed)
|
||||||
if (m.direction === 'outgoing' && m.status) {
|
var text = m.message_text || m.text || '';
|
||||||
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
|
// Support both 'created_at' and 'date' keys
|
||||||
}
|
var time = m.created_at || m.date || '';
|
||||||
html += '<div class="msg-bubble ' + cls + '">'
|
html += '<div class="msg-bubble ' + cls + '">'
|
||||||
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
|
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||||
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
|
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
});
|
});
|
||||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||||
@@ -328,17 +388,51 @@
|
|||||||
if (quoteBtn) {
|
if (quoteBtn) {
|
||||||
quoteBtn.addEventListener('click', function () {
|
quoteBtn.addEventListener('click', function () {
|
||||||
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
|
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
|
||||||
var quoteId = prompt('ID de la cotizacion a enviar:');
|
|
||||||
|
// Fetch available quotations and let user pick one
|
||||||
|
fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; });
|
||||||
|
if (quotes.length === 0) {
|
||||||
|
alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var msg = 'Cotizaciones activas:\n';
|
||||||
|
quotes.forEach(function (q) {
|
||||||
|
msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n';
|
||||||
|
});
|
||||||
|
var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:');
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
|
|
||||||
|
// Fetch the quotation detail and send it formatted
|
||||||
|
fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (q) {
|
||||||
|
if (q.error) { alert('Error: ' + q.error); return; }
|
||||||
|
// Format the quotation as a WhatsApp message
|
||||||
|
var lines = ['📄 *COTIZACIÓN #' + q.id + '*', ''];
|
||||||
|
(q.items || []).forEach(function (it, i) {
|
||||||
|
lines.push((i + 1) + '. ' + it.name);
|
||||||
|
lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2));
|
||||||
|
});
|
||||||
|
lines.push('─────────────');
|
||||||
|
lines.push('Subtotal: $' + q.subtotal.toFixed(2));
|
||||||
|
lines.push('IVA: $' + q.tax_total.toFixed(2));
|
||||||
|
lines.push('*TOTAL: $' + q.total.toFixed(2) + '*');
|
||||||
|
|
||||||
|
var text = lines.join('\n');
|
||||||
|
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
alert('Error: ' + res.error);
|
alert('Error enviando: ' + res.error);
|
||||||
} else {
|
} else {
|
||||||
loadMessages(activePhone);
|
loadMessages(activePhone);
|
||||||
loadConversations();
|
loadConversations();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Polling for new messages ----------------------------------------------
|
// -- Polling for new messages ----------------------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Contabilidad — Nexus Autoparts POS</title>
|
<title>Contabilidad — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1732,6 +1733,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/accounting.js"></script>
|
<script src="/pos/static/js/accounting.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.js"></script>
|
<script src="/pos/static/js/sync-engine.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Catalogo — Nexus Autoparts POS</title>
|
<title>Catalogo — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
@@ -106,6 +107,41 @@
|
|||||||
|
|
||||||
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
|
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
|
||||||
|
|
||||||
|
/* ── Catalog mode toggle (OEM / Local) ── */
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mode-toggle button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: calc(var(--radius-md) - 3px);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
.mode-toggle button:hover {
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
.mode-toggle button.is-active {
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
/* Search bar */
|
/* Search bar */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
display: flex; align-items: center; gap: var(--space-2);
|
display: flex; align-items: center; gap: var(--space-2);
|
||||||
@@ -233,8 +269,39 @@
|
|||||||
|
|
||||||
.part-card__body { padding: var(--space-3) var(--space-4); flex: 1; }
|
.part-card__body { padding: var(--space-3) var(--space-4); flex: 1; }
|
||||||
.part-card__oem { font-family: var(--font-mono, monospace); font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); margin-bottom: var(--space-1); }
|
.part-card__oem { font-family: var(--font-mono, monospace); font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); margin-bottom: var(--space-1); }
|
||||||
|
.part-card__oem-sub { font-family: var(--font-mono, monospace); font-size: 10px; color: var(--color-text-muted); font-weight: var(--font-weight-regular); }
|
||||||
.part-card__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); line-height: 1.3; }
|
.part-card__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); line-height: 1.3; }
|
||||||
|
|
||||||
|
/* Local mode — manufacturer badge + priority tier */
|
||||||
|
.part-card__manu {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 2px 8px; margin-bottom: var(--space-1);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.part-card__manu .manu-tier {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.part-card--tier1 {
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
box-shadow: 0 0 12px var(--glow-color-soft);
|
||||||
|
}
|
||||||
|
.part-card--tier1 .part-card__manu {
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
border-color: var(--color-border-accent);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
.part-card--tier2 .part-card__manu {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.part-card__footer {
|
.part-card__footer {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
@@ -592,6 +659,11 @@
|
|||||||
<span class="breadcrumb__current">Catalogo</span>
|
<span class="breadcrumb__current">Catalogo</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions" style="position:relative;">
|
<div class="header-actions" style="position:relative;">
|
||||||
|
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales y consumibles">
|
||||||
|
<button data-mode="oem" onclick="CatalogApp.setMode('oem')">OEM</button>
|
||||||
|
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
|
||||||
|
<button data-mode="supplies" onclick="CatalogApp.setMode('supplies')" title="Aceites, quimicos, herramientas — sin vehiculo">Supplies</button>
|
||||||
|
</div>
|
||||||
<div class="search-bar" id="searchBar">
|
<div class="search-bar" id="searchBar">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
|
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
|
||||||
@@ -751,6 +823,7 @@
|
|||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/kiosk.js"></script>
|
<script src="/pos/static/js/kiosk.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/catalog.js"></script>
|
<script src="/pos/static/js/catalog.js"></script>
|
||||||
<script src="/pos/static/js/offline-banner.js"></script>
|
<script src="/pos/static/js/offline-banner.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Configuración — Nexus Autoparts POS</title>
|
<title>Configuración — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1332,34 +1333,36 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Razón Social</label>
|
<label class="form-label">Razón Social</label>
|
||||||
<input class="form-input" id="biz-razon-social" type="text" value="" readonly />
|
<input class="form-input" id="biz-razon-social" type="text" value="" placeholder="Ej: Refacciones El Toro S.A. de C.V." />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Nombre Comercial</label>
|
<label class="form-label">Nombre Comercial</label>
|
||||||
<input class="form-input" id="biz-nombre" type="text" value="" readonly />
|
<input class="form-input" id="biz-nombre" type="text" value="" placeholder="Ej: Refacciones El Toro" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">RFC</label>
|
<label class="form-label">RFC</label>
|
||||||
<input class="form-input" id="biz-rfc" type="text" value="" readonly />
|
<input class="form-input" id="biz-rfc" type="text" value="" placeholder="Ej: RET260101ABC" maxlength="13" style="text-transform:uppercase;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Régimen Fiscal</label>
|
<label class="form-label">Régimen Fiscal</label>
|
||||||
<input class="form-input" id="biz-regimen" type="text" value="" readonly />
|
<input class="form-input" id="biz-regimen" type="text" value="" placeholder="Ej: 601 - General de Ley" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group form-group--full">
|
<div class="form-group form-group--full">
|
||||||
<label class="form-label">Dirección Fiscal</label>
|
<label class="form-label">Dirección Fiscal</label>
|
||||||
<input class="form-input" id="biz-direccion" type="text" value="" readonly />
|
<input class="form-input" id="biz-direccion" type="text" value="" placeholder="Calle, Numero, Colonia, CP, Ciudad" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Teléfono</label>
|
<label class="form-label">Teléfono</label>
|
||||||
<input class="form-input" id="biz-telefono" type="tel" value="" readonly />
|
<input class="form-input" id="biz-telefono" type="tel" value="" placeholder="Ej: 664-123-4567" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input class="form-input" id="biz-email" type="email" value="" readonly />
|
<input class="form-input" id="biz-email" type="email" value="" placeholder="Ej: contacto@refacciones.com" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint" style="margin-top: var(--space-3);">Datos configurados durante el aprovisionamiento del tenant. Contacta soporte para cambios.</p>
|
<div style="margin-top:var(--space-4);text-align:right;">
|
||||||
|
<button class="btn btn--primary" onclick="Config.saveBusiness()">Guardar datos de empresa</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1420,60 +1423,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-grid">
|
<div class="device-grid" id="printerGrid">
|
||||||
<div class="device-card">
|
<div class="device-card" style="border-style:dashed;text-align:center;color:var(--color-text-muted);padding:var(--space-8);">
|
||||||
<div class="device-card__icon">
|
<div style="font-size:2rem;margin-bottom:var(--space-3);">🖨️</div>
|
||||||
<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>
|
<div>Sin impresoras configuradas</div>
|
||||||
</div>
|
<div style="font-size:var(--text-caption);margin-top:var(--space-2);">
|
||||||
<div class="device-card__body">
|
La configuracion de impresoras se hace desde el navegador.<br>
|
||||||
<div class="device-card__name">Epson TM-T88VI</div>
|
Ve a <strong>chrome://devices</strong> o usa <strong>Ctrl+P</strong> para imprimir.
|
||||||
<div class="device-card__detail">
|
|
||||||
<span class="badge badge--ok" style="padding: 0 4px; font-size: 0.625rem;">En línea</span>
|
|
||||||
Tickets de venta
|
|
||||||
</div>
|
|
||||||
<div class="device-card__detail">USB · 192.168.10.50</div>
|
|
||||||
<div class="device-card__detail">Predeterminada para POS</div>
|
|
||||||
<div class="device-card__actions">
|
|
||||||
<button class="btn btn--ghost btn--sm">Configurar</button>
|
|
||||||
<button class="btn btn--ghost btn--sm">Test</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="device-card">
|
|
||||||
<div class="device-card__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>
|
|
||||||
</div>
|
|
||||||
<div class="device-card__body">
|
|
||||||
<div class="device-card__name">Zebra GK420d</div>
|
|
||||||
<div class="device-card__detail">
|
|
||||||
<span class="badge badge--ok" style="padding: 0 4px; font-size: 0.625rem;">En línea</span>
|
|
||||||
Etiquetas de código de barras
|
|
||||||
</div>
|
|
||||||
<div class="device-card__detail">USB · 192.168.10.51</div>
|
|
||||||
<div class="device-card__detail">Predeterminada para inventario</div>
|
|
||||||
<div class="device-card__actions">
|
|
||||||
<button class="btn btn--ghost btn--sm">Configurar</button>
|
|
||||||
<button class="btn btn--ghost btn--sm">Test</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="device-card">
|
|
||||||
<div class="device-card__icon" style="background: rgba(115,115,115,.12);">
|
|
||||||
<svg viewBox="0 0 24 24" style="stroke: var(--color-text-muted);"><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>
|
|
||||||
</div>
|
|
||||||
<div class="device-card__body">
|
|
||||||
<div class="device-card__name">HP LaserJet Pro M404</div>
|
|
||||||
<div class="device-card__detail">
|
|
||||||
<span class="badge badge--inactive" style="padding: 0 4px; font-size: 0.625rem;">Fuera de línea</span>
|
|
||||||
Facturas y reportes
|
|
||||||
</div>
|
|
||||||
<div class="device-card__detail">Red · 192.168.10.52</div>
|
|
||||||
<div class="device-card__actions">
|
|
||||||
<button class="btn btn--ghost btn--sm">Configurar</button>
|
|
||||||
<button class="btn btn--ghost btn--sm">Reintentar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1595,41 +1551,41 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Tasa IVA (%)</label>
|
<label class="form-label">Tasa IVA (%)</label>
|
||||||
<input class="form-input" type="number" value="16" />
|
<input class="form-input" id="tax-iva" type="number" value="16" step="1" min="0" max="100" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Tasa IEPS (%)</label>
|
<label class="form-label">Tasa IEPS (%)</label>
|
||||||
<input class="form-input" type="number" value="0" />
|
<input class="form-input" id="tax-ieps" type="number" value="0" step="1" min="0" />
|
||||||
<span class="form-hint">Dejar en 0 si no aplica</span>
|
<span class="form-hint">Dejar en 0 si no aplica</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Serie de Facturación</label>
|
<label class="form-label">Serie de Facturación</label>
|
||||||
<input class="form-input" type="text" value="FA" />
|
<input class="form-input" id="tax-serie" type="text" value="FA" maxlength="10" style="text-transform:uppercase;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Folio Actual</label>
|
<label class="form-label">Folio Actual</label>
|
||||||
<input class="form-input" type="number" value="893" />
|
<input class="form-input" id="tax-folio" type="number" value="1" min="1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Moneda Predeterminada</label>
|
<label class="form-label">Moneda Predeterminada</label>
|
||||||
<select class="form-select">
|
<select class="form-select" id="tax-moneda">
|
||||||
<option selected>MXN — Peso Mexicano</option>
|
<option value="MXN">MXN — Peso Mexicano</option>
|
||||||
<option>USD — Dólar Americano</option>
|
<option value="USD">USD — Dólar Americano</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Forma de Pago Default</label>
|
<label class="form-label">Forma de Pago Default</label>
|
||||||
<select class="form-select">
|
<select class="form-select" id="tax-forma-pago">
|
||||||
<option selected>01 — Efectivo</option>
|
<option value="01">01 — Efectivo</option>
|
||||||
<option>03 — Transferencia Electrónica</option>
|
<option value="03">03 — Transferencia Electrónica</option>
|
||||||
<option>04 — Tarjeta de Crédito</option>
|
<option value="04">04 — Tarjeta de Crédito</option>
|
||||||
<option>28 — Tarjeta de Débito</option>
|
<option value="28">28 — Tarjeta de Débito</option>
|
||||||
<option>99 — Por Definir</option>
|
<option value="99">99 — Por Definir</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn--primary btn--sm">Guardar Parámetros</button>
|
<button class="btn btn--primary btn--sm" onclick="Config.saveTaxParams()">Guardar Parámetros</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1926,6 +1882,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/kiosk.js"></script>
|
<script src="/pos/static/js/kiosk.js"></script>
|
||||||
<script src="/pos/static/js/config.js"></script>
|
<script src="/pos/static/js/config.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Clientes</title>
|
<title>Nexus Autoparts — Clientes</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1721,13 +1722,41 @@
|
|||||||
<div class="page-header__subtitle">Directorio, crédito y historial de compras</div>
|
<div class="page-header__subtitle">Directorio, crédito y historial de compras</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button class="btn btn-ghost">
|
<button class="btn btn-ghost" onclick="openCustomerFilters(this)">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 5h12M4 8h8M6 11h4"/></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 5h12M4 8h8M6 11h4"/></svg>
|
||||||
Filtros
|
Filtros
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost">
|
<script>
|
||||||
|
function openCustomerFilters(btn) {
|
||||||
|
var table = document.querySelector('table');
|
||||||
|
if (!table) { showToast('Carga la lista de clientes primero', 'warn'); return; }
|
||||||
|
// Auto-detect columns: look at headers to find the right indexes
|
||||||
|
var ths = table.querySelectorAll('thead th');
|
||||||
|
var colMap = {};
|
||||||
|
ths.forEach(function(th, i) {
|
||||||
|
var t = th.textContent.trim().toLowerCase();
|
||||||
|
if (t.indexOf('tipo') !== -1 || t.indexOf('tier') !== -1) colMap.tipo = i;
|
||||||
|
if (t.indexOf('ciudad') !== -1 || t.indexOf('city') !== -1) colMap.ciudad = i;
|
||||||
|
if (t.indexOf('crédito') !== -1 || t.indexOf('credito') !== -1 || t.indexOf('credit') !== -1) colMap.credito = i;
|
||||||
|
if (t.indexOf('status') !== -1 || t.indexOf('estado') !== -1) colMap.status = i;
|
||||||
|
});
|
||||||
|
var filters = [];
|
||||||
|
if (colMap.tipo !== undefined) filters.push({label:'Tipo', column: colMap.tipo, values: getUniqueColumnValues(table, colMap.tipo)});
|
||||||
|
if (colMap.credito !== undefined) filters.push({label:'Crédito', column: colMap.credito, values: getUniqueColumnValues(table, colMap.credito)});
|
||||||
|
if (colMap.ciudad !== undefined) filters.push({label:'Ciudad', column: colMap.ciudad, values: getUniqueColumnValues(table, colMap.ciudad)});
|
||||||
|
if (colMap.status !== undefined) filters.push({label:'Estado', column: colMap.status, values: getUniqueColumnValues(table, colMap.status)});
|
||||||
|
if (filters.length === 0) {
|
||||||
|
// Fallback: use first 3 columns
|
||||||
|
for (var i = 1; i < Math.min(4, ths.length); i++) {
|
||||||
|
filters.push({label: ths[i].textContent.trim(), column: i, values: getUniqueColumnValues(table, i)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleFilterPanel(btn, filters);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<button class="btn btn-ghost" onclick="exportVisibleTableCSV('clientes')">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 10v3a1 1 0 01-1 1H3a1 1 0 01-1-1v-3M8 1v9M4 6l4 4 4-4"/></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 10v3a1 1 0 01-1 1H3a1 1 0 01-1-1v-3M8 1v9M4 6l4 4 4-4"/></svg>
|
||||||
Exportar
|
Exportar CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" onclick="openNewCustomerModal()">
|
<button class="btn btn-primary" onclick="openNewCustomerModal()">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/></svg>
|
||||||
@@ -2149,6 +2178,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/customers.js"></script>
|
<script src="/pos/static/js/customers.js"></script>
|
||||||
<script src="/pos/static/js/offline-banner.js"></script>
|
<script src="/pos/static/js/offline-banner.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Dashboard</title>
|
<title>Nexus Autoparts — Dashboard</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1687,6 +1688,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/dashboard.js"></script>
|
<script src="/pos/static/js/dashboard.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.js"></script>
|
<script src="/pos/static/js/sync-engine.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Diagramas — Nexus Autoparts POS</title>
|
<title>Diagramas — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
@@ -606,6 +607,7 @@
|
|||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/kiosk.js"></script>
|
<script src="/pos/static/js/kiosk.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/diagrams.js"></script>
|
<script src="/pos/static/js/diagrams.js"></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>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Flotillas — Nexus Autoparts POS</title>
|
<title>Flotillas — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -964,7 +965,8 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/fleet.js"></script>
|
<script src="/pos/static/js/fleet.js"></script>
|
||||||
<script src="/pos/static/js/offline-banner.js"></script>
|
<script src="/pos/static/js/offline-banner.js"></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>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Inventario — Nexus Autoparts POS</title>
|
<title>Inventario — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1468,9 +1469,9 @@
|
|||||||
<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="alert('Exportar: próximamente')">
|
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('inventario')">
|
||||||
<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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
Exportar
|
Exportar CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--ghost" onclick="loadItems(1,'')">
|
<button class="btn btn--ghost" onclick="loadItems(1,'')">
|
||||||
<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>
|
||||||
@@ -1585,14 +1586,24 @@
|
|||||||
<option>OK</option><option>Bajo</option><option>Sobrestock</option>
|
<option>OK</option><option>Bajo</option><option>Sobrestock</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button class="btn btn--ghost btn--sm">
|
<button class="btn btn--ghost btn--sm" onclick="openInventoryFilters(this)">
|
||||||
<svg viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
<svg viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||||
Filtros
|
Filtros
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--ghost btn--sm">
|
<script>
|
||||||
<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>
|
function openInventoryFilters(btn) {
|
||||||
Columnas
|
var table = document.querySelector('table');
|
||||||
</button>
|
if (!table) { showToast('Carga el inventario primero', 'warn'); return; }
|
||||||
|
var brands = getUniqueColumnValues(table, 3); // brand column
|
||||||
|
var categories = getUniqueColumnValues(table, 4); // category column
|
||||||
|
var statuses = getUniqueColumnValues(table, 5); // stock status column
|
||||||
|
toggleFilterPanel(btn, [
|
||||||
|
{label: 'Marca', column: 3, values: brands},
|
||||||
|
{label: 'Categoría', column: 4, values: categories},
|
||||||
|
{label: 'Estado Stock', column: 5, values: statuses},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
<button class="btn btn--primary btn--sm" 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
|
||||||
@@ -2097,6 +2108,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/inventory.js"></script>
|
<script src="/pos/static/js/inventory.js"></script>
|
||||||
<script src="/pos/static/js/offline-banner.js"></script>
|
<script src="/pos/static/js/offline-banner.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Facturación CFDI — Nexus Autoparts POS</title>
|
<title>Facturación CFDI — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1516,15 +1517,43 @@
|
|||||||
<h1 class="page-header__title">Facturación CFDI</h1>
|
<h1 class="page-header__title">Facturación CFDI</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button class="btn btn--ghost">
|
<button class="btn btn--ghost" onclick="openInvoiceFilters(this)">
|
||||||
|
<svg viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||||
|
Filtros
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('facturacion')">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
Exportar
|
Exportar CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--secondary" onclick="window.notaCreditoPlaceholder()">
|
<script>
|
||||||
|
function openInvoiceFilters(btn) {
|
||||||
|
var table = document.querySelector('table');
|
||||||
|
if (!table) { showToast('Carga las facturas primero', 'warn'); return; }
|
||||||
|
var ths = table.querySelectorAll('thead th');
|
||||||
|
var colMap = {};
|
||||||
|
ths.forEach(function(th, i) {
|
||||||
|
var t = th.textContent.trim().toLowerCase();
|
||||||
|
if (t.indexOf('status') !== -1 || t.indexOf('estado') !== -1) colMap.status = i;
|
||||||
|
if (t.indexOf('cliente') !== -1 || t.indexOf('receptor') !== -1) colMap.cliente = i;
|
||||||
|
if (t.indexOf('tipo') !== -1) colMap.tipo = i;
|
||||||
|
});
|
||||||
|
var filters = [];
|
||||||
|
if (colMap.status !== undefined) filters.push({label:'Estado', column: colMap.status, values: getUniqueColumnValues(table, colMap.status)});
|
||||||
|
if (colMap.tipo !== undefined) filters.push({label:'Tipo', column: colMap.tipo, values: getUniqueColumnValues(table, colMap.tipo)});
|
||||||
|
if (colMap.cliente !== undefined) filters.push({label:'Cliente', column: colMap.cliente, values: getUniqueColumnValues(table, colMap.cliente, 15)});
|
||||||
|
if (filters.length === 0) {
|
||||||
|
for (var i = 1; i < Math.min(3, ths.length); i++) {
|
||||||
|
filters.push({label: ths[i].textContent.trim(), column: i, values: getUniqueColumnValues(table, i)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleFilterPanel(btn, filters);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<button class="btn btn--secondary" onclick="showToast('Nota de Crédito requiere integración SAT — disponible en siguiente actualización', 'info')">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M9 14l-4-4 4-4"/>
|
<path d="M9 14l-4-4 4-4"/>
|
||||||
<path d="M5 10h11a4 4 0 0 1 0 8h-1"/>
|
<path d="M5 10h11a4 4 0 0 1 0 8h-1"/>
|
||||||
@@ -2359,6 +2388,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/invoicing.js"></script>
|
<script src="/pos/static/js/invoicing.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.js"></script>
|
<script src="/pos/static/js/sync-engine.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Iniciar Sesión</title>
|
<title>Nexus Autoparts — Iniciar Sesión</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Punto de Venta</title>
|
<title>Nexus Autoparts — Punto de Venta</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
@@ -1481,6 +1482,7 @@
|
|||||||
================================================================ -->
|
================================================================ -->
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/kiosk.js"></script>
|
<script src="/pos/static/js/kiosk.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
<script src="/pos/static/js/push.js"></script>
|
<script src="/pos/static/js/push.js"></script>
|
||||||
<script src="/pos/static/js/printer.js"></script>
|
<script src="/pos/static/js/printer.js"></script>
|
||||||
|
|||||||
175
pos/templates/quotations.html
Normal file
175
pos/templates/quotations.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es" data-theme="industrial">
|
||||||
|
<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>Cotizaciones — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-body); background: var(--color-bg-base); color: var(--color-text-primary); min-height: 100vh; }
|
||||||
|
.page { max-width: 1200px; margin: 0 auto; padding: var(--space-6); margin-left: 240px; }
|
||||||
|
@media (max-width: 1023px) { .page { margin-left: 0; } }
|
||||||
|
.page-title { font-family: var(--font-heading); font-size: var(--text-h3); margin-bottom: var(--space-6); }
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.quote-table th, .quote-table td { padding: var(--space-3) var(--space-4); text-align: left; border-bottom: 1px solid var(--glass-border); }
|
||||||
|
.quote-table th { background: var(--glass-bg-strong); font-family: var(--font-mono); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); }
|
||||||
|
.quote-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
||||||
|
.quote-table tbody tr:hover { background: var(--glass-highlight); }
|
||||||
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
||||||
|
.badge--active { background: rgba(63,185,80,0.15); color: #3FB950; }
|
||||||
|
.badge--converted { background: rgba(0,212,255,0.15); color: #00D4FF; }
|
||||||
|
.badge--cancelled { background: rgba(248,81,73,0.15); color: #F85149; }
|
||||||
|
.badge--expired { background: rgba(130,130,130,0.2); color: #888; }
|
||||||
|
.badge--wa { background: rgba(37,211,102,0.15); color: #25D366; }
|
||||||
|
.badge--pos { background: var(--color-primary-muted); color: var(--color-text-accent); }
|
||||||
|
.modal-overlay { display:none; position:fixed; inset:0; z-index:1000; background:var(--overlay-backdrop); backdrop-filter:blur(4px); align-items:flex-start; justify-content:center; padding:var(--space-8) var(--space-4); overflow-y:auto; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal-content { background:var(--glass-bg-strong); backdrop-filter:blur(24px); border:1px solid var(--glass-border); border-radius:var(--radius-lg); max-width:650px; width:100%; padding:var(--space-6); position:relative; }
|
||||||
|
.modal-close { position:absolute; top:var(--space-3); right:var(--space-3); background:none; border:none; color:var(--color-text-muted); font-size:1.4rem; cursor:pointer; }
|
||||||
|
.detail-table { width:100%; border-collapse:collapse; margin:var(--space-4) 0; }
|
||||||
|
.detail-table th, .detail-table td { padding:var(--space-2) var(--space-3); text-align:left; border-bottom:1px solid var(--glass-border); font-size:var(--text-body-sm); }
|
||||||
|
.detail-table th { color:var(--color-text-muted); font-size:var(--text-caption); text-transform:uppercase; }
|
||||||
|
.empty { text-align:center; padding:var(--space-12); color:var(--color-text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="page-title">Cotizaciones</h1>
|
||||||
|
<div id="quoteList">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="quoteModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="modal-close" onclick="document.getElementById('quoteModal').classList.remove('open')">×</button>
|
||||||
|
<div id="quoteDetail">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var token = localStorage.getItem('pos_token');
|
||||||
|
if (!token) { window.location.href = '/pos/login'; return; }
|
||||||
|
var API = '/pos/api';
|
||||||
|
|
||||||
|
function headers() { return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; }
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||||
|
function fmt(n) { return (n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||||
|
|
||||||
|
function loadQuotes() {
|
||||||
|
fetch(API + '/quotations?per_page=50', { headers: headers() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
var quotes = d.data || [];
|
||||||
|
if (!quotes.length) {
|
||||||
|
document.getElementById('quoteList').innerHTML = '<div class="empty"><h3>Sin cotizaciones</h3><p>Las cotizaciones creadas desde el POS (F4) o desde WhatsApp aparecen aqui.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table class="quote-table"><thead><tr>';
|
||||||
|
html += '<th>#</th><th>Origen</th><th>Cliente</th><th>Total</th><th>Estado</th><th>Fecha</th><th></th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
quotes.forEach(function(q) {
|
||||||
|
var srcBadge = q.source === 'whatsapp'
|
||||||
|
? '<span class="badge badge--wa">📱 WA</span>'
|
||||||
|
: '<span class="badge badge--pos">🖥️ POS</span>';
|
||||||
|
var statusBadge = '<span class="badge badge--' + q.status + '">' + q.status + '</span>';
|
||||||
|
var client = q.customer_name || (q.wa_phone ? '📱 ' + q.wa_phone : 'Sin cliente');
|
||||||
|
var dateStr = q.created_at ? new Date(q.created_at).toLocaleDateString('es-MX') : '';
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;"><strong>#' + q.id + '</strong></td>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + srcBadge + '</td>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + esc(client) + '</td>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
|
||||||
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
|
||||||
|
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('quoteList').innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('quoteList').innerHTML = '<div class="empty">Error cargando cotizaciones</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openQuote = function(id) {
|
||||||
|
var modal = document.getElementById('quoteModal');
|
||||||
|
modal.classList.add('open');
|
||||||
|
document.getElementById('quoteDetail').innerHTML = 'Cargando...';
|
||||||
|
|
||||||
|
fetch(API + '/quotations/' + id, { headers: headers() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(q) {
|
||||||
|
if (q.error) { document.getElementById('quoteDetail').innerHTML = 'Error: ' + esc(q.error); return; }
|
||||||
|
var src = (q.notes || '').startsWith('WA:') ? 'WhatsApp' : 'POS';
|
||||||
|
var waPhone = src === 'WhatsApp' ? q.notes.replace('WA:', '') : null;
|
||||||
|
var html = '<h3 style="font-family:var(--font-heading);margin-bottom:var(--space-4);">Cotización #' + q.id + '</h3>';
|
||||||
|
html += '<div style="display:flex;gap:var(--space-6);margin-bottom:var(--space-4);font-size:var(--text-body-sm);">';
|
||||||
|
html += '<div><span style="color:var(--color-text-muted);">Origen:</span> ' + src + '</div>';
|
||||||
|
if (waPhone) html += '<div><span style="color:var(--color-text-muted);">WhatsApp:</span> +' + esc(waPhone) + '</div>';
|
||||||
|
if (q.customer_name) html += '<div><span style="color:var(--color-text-muted);">Cliente:</span> ' + esc(q.customer_name) + '</div>';
|
||||||
|
html += '<div><span style="color:var(--color-text-muted);">Estado:</span> <span class="badge badge--' + q.status + '">' + q.status + '</span></div>';
|
||||||
|
html += '<div><span style="color:var(--color-text-muted);">Vigencia:</span> ' + (q.valid_until || '—') + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<table class="detail-table"><thead><tr><th>#Parte</th><th>Nombre</th><th>Cant</th><th>Precio</th><th>Subtotal</th></tr></thead><tbody>';
|
||||||
|
(q.items || []).forEach(function(it) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td style="font-family:var(--font-mono);">' + esc(it.part_number) + '</td>';
|
||||||
|
html += '<td>' + esc(it.name) + '</td>';
|
||||||
|
html += '<td>' + it.quantity + '</td>';
|
||||||
|
html += '<td>$' + fmt(it.unit_price) + '</td>';
|
||||||
|
html += '<td style="font-weight:700;">$' + fmt(it.subtotal) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
html += '<div style="text-align:right;margin-top:var(--space-4);font-size:var(--text-body);">';
|
||||||
|
html += '<div>Subtotal: $' + fmt(q.subtotal) + '</div>';
|
||||||
|
html += '<div>IVA: $' + fmt(q.tax_total) + '</div>';
|
||||||
|
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;">';
|
||||||
|
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
|
||||||
|
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
|
||||||
|
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
document.getElementById('quoteDetail').innerHTML = html;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteQuote = function(id, event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
if (!confirm('¿Eliminar cotización #' + id + '? Esta acción no se puede deshacer.')) return;
|
||||||
|
fetch(API + '/quotations/' + id, { method: 'DELETE', headers: headers() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.ok) {
|
||||||
|
document.getElementById('quoteModal').classList.remove('open');
|
||||||
|
loadQuotes();
|
||||||
|
if (typeof showToast === 'function') showToast('Cotización #' + id + ' eliminada', 'ok');
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (d.error || 'desconocido'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal on outside click
|
||||||
|
document.getElementById('quoteModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
loadQuotes();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Reportes — Nexus Autoparts POS</title>
|
<title>Reportes — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -1690,17 +1691,17 @@
|
|||||||
<h1 class="content-header__title">Reportes</h1>
|
<h1 class="content-header__title">Reportes</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-header__actions">
|
<div class="content-header__actions">
|
||||||
<button class="btn btn-ghost">
|
<button class="btn btn-ghost" onclick="exportReportCSV()">
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
||||||
<rect x="1" y="1" width="12" height="12" rx="1"/><line x1="4" y1="5" x2="10" y2="5"/><line x1="4" y1="8" x2="8" y2="8"/>
|
<rect x="1" y="1" width="12" height="12" rx="1"/><line x1="4" y1="5" x2="10" y2="5"/><line x1="4" y1="8" x2="8" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
Exportar Excel
|
Exportar Excel (CSV)
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary" onclick="window.print()">
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
||||||
<rect x="2" y="1" width="10" height="12" rx="1"/><line x1="5" y1="5" x2="9" y2="5"/><line x1="5" y1="7" x2="9" y2="7"/><line x1="5" y1="9" x2="7" y2="9"/>
|
<rect x="2" y="1" width="10" height="12" rx="1"/><line x1="5" y1="5" x2="9" y2="5"/><line x1="5" y1="7" x2="9" y2="7"/><line x1="5" y1="9" x2="7" y2="9"/>
|
||||||
</svg>
|
</svg>
|
||||||
Exportar PDF
|
Exportar PDF (Imprimir)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1848,6 +1849,7 @@
|
|||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/reports.js"></script>
|
<script src="/pos/static/js/reports.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.js"></script>
|
<script src="/pos/static/js/sync-engine.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WhatsApp — Nexus Autoparts POS</title>
|
<title>WhatsApp — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
@@ -200,6 +201,24 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conv-item__delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.conv-item:hover .conv-item__delete { opacity: 1; }
|
||||||
|
.conv-item__delete:hover { color: #F85149; background: rgba(248,81,73,0.1); }
|
||||||
|
.conv-item { position: relative; }
|
||||||
|
|
||||||
.conv-empty {
|
.conv-empty {
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -611,7 +630,8 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/whatsapp.js"></script>
|
<script src="/pos/static/js/whatsapp.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/pos-utils.js"></script>
|
||||||
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
262
scripts/import_inventory.py
Executable file
262
scripts/import_inventory.py
Executable file
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Nexus Autoparts — Inventory CSV Import Tool
|
||||||
|
|
||||||
|
Imports a refaccionaria's inventory from a CSV file into their tenant DB.
|
||||||
|
Handles flexible column names (Spanish, English, SICAR format) and creates
|
||||||
|
inventory + initial stock operations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_inventory.py --tenant=12 --csv=inventario.csv
|
||||||
|
python3 import_inventory.py --tenant=12 --csv=inventario.csv --branch=1 --dry-run
|
||||||
|
|
||||||
|
The CSV should have at least these columns (names are flexible):
|
||||||
|
part_number / numero_de_parte / codigo / clave / sku
|
||||||
|
name / nombre / descripcion
|
||||||
|
price / precio / precio_1 / precio_venta
|
||||||
|
stock / existencia / cantidad / qty
|
||||||
|
|
||||||
|
Optional columns:
|
||||||
|
brand / marca
|
||||||
|
cost / costo / precio_compra
|
||||||
|
price_2 / precio_2 / precio_mayoreo
|
||||||
|
price_3 / precio_3 / precio_credito
|
||||||
|
category / categoria / linea / departamento
|
||||||
|
location / ubicacion
|
||||||
|
min_stock / minimo / stock_minimo
|
||||||
|
unit / unidad (default: PZA)
|
||||||
|
barcode / codigo_barras
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Column name mapping (flexible) ──────────────────────────────────
|
||||||
|
|
||||||
|
COLUMN_ALIASES = {
|
||||||
|
'part_number': ['part_number', 'numero_de_parte', 'codigo', 'clave', 'sku', 'no_parte', 'num_parte', 'numero', 'code'],
|
||||||
|
'name': ['name', 'nombre', 'descripcion', 'description', 'producto', 'articulo'],
|
||||||
|
'brand': ['brand', 'marca', 'fabricante', 'manufacturer'],
|
||||||
|
'price': ['price', 'precio', 'precio_1', 'precio_venta', 'pvp', 'price_1'],
|
||||||
|
'cost': ['cost', 'costo', 'precio_compra', 'costo_unitario'],
|
||||||
|
'stock': ['stock', 'existencia', 'cantidad', 'qty', 'quantity', 'inventario', 'saldo'],
|
||||||
|
'price_2': ['price_2', 'precio_2', 'precio_mayoreo', 'mayoreo'],
|
||||||
|
'price_3': ['price_3', 'precio_3', 'precio_credito', 'credito'],
|
||||||
|
'category': ['category', 'categoria', 'linea', 'departamento', 'rubro', 'grupo'],
|
||||||
|
'location': ['location', 'ubicacion', 'almacen', 'warehouse'],
|
||||||
|
'min_stock': ['min_stock', 'minimo', 'stock_minimo', 'reorden', 'min'],
|
||||||
|
'unit': ['unit', 'unidad', 'um'],
|
||||||
|
'barcode': ['barcode', 'codigo_barras', 'ean', 'upc'],
|
||||||
|
'tax_rate': ['tax_rate', 'iva', 'impuesto', 'tasa_iva'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_columns(header: list[str]) -> dict:
|
||||||
|
"""Map CSV header names to our standard field names.
|
||||||
|
|
||||||
|
Returns: {standard_name: csv_column_name} for each matched field.
|
||||||
|
"""
|
||||||
|
mapping = {}
|
||||||
|
normalized = {h.strip().lower().replace(' ', '_'): h for h in header}
|
||||||
|
|
||||||
|
for field, aliases in COLUMN_ALIASES.items():
|
||||||
|
for alias in aliases:
|
||||||
|
if alias in normalized:
|
||||||
|
mapping[field] = normalized[alias]
|
||||||
|
break
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def parse_number(val, default=0):
|
||||||
|
"""Parse a string that might be '$1,234.56' or '1234.56' or empty."""
|
||||||
|
if not val:
|
||||||
|
return default
|
||||||
|
cleaned = str(val).replace('$', '').replace(',', '').replace(' ', '').strip()
|
||||||
|
try:
|
||||||
|
return float(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Import inventory CSV into a Nexus tenant')
|
||||||
|
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
|
||||||
|
parser.add_argument('--csv', required=True, help='Path to CSV file')
|
||||||
|
parser.add_argument('--branch', type=int, default=1, help='Branch ID (default: 1)')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Parse but don\'t insert')
|
||||||
|
parser.add_argument('--encoding', default='utf-8', help='CSV encoding (default: utf-8, try latin-1 for SICAR)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Read CSV
|
||||||
|
csv_path = args.csv
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f'ERROR: File not found: {csv_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(csv_path, 'r', encoding=args.encoding, errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Detect delimiter (comma, semicolon, tab)
|
||||||
|
first_line = content.split('\n')[0]
|
||||||
|
if '\t' in first_line:
|
||||||
|
delimiter = '\t'
|
||||||
|
elif ';' in first_line and ',' not in first_line:
|
||||||
|
delimiter = ';'
|
||||||
|
else:
|
||||||
|
delimiter = ','
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
print('ERROR: CSV has no header row')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
col_map = resolve_columns(reader.fieldnames)
|
||||||
|
print(f'CSV columns detected: {list(reader.fieldnames)}')
|
||||||
|
print(f'Mapped to: {col_map}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Validate required columns
|
||||||
|
required = ['part_number', 'name']
|
||||||
|
missing = [r for r in required if r not in col_map]
|
||||||
|
if missing:
|
||||||
|
print(f'ERROR: Required columns not found: {missing}')
|
||||||
|
print(f'Available: {list(reader.fieldnames)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if 'price' not in col_map:
|
||||||
|
print('WARNING: No price column found — all prices will be 0')
|
||||||
|
if 'stock' not in col_map:
|
||||||
|
print('WARNING: No stock column found — all stock will be 0')
|
||||||
|
|
||||||
|
# Connect to tenant DB
|
||||||
|
conn = get_tenant_conn(args.tenant)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Parse rows
|
||||||
|
rows_parsed = 0
|
||||||
|
rows_inserted = 0
|
||||||
|
rows_updated = 0
|
||||||
|
rows_skipped = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def get_val(row, field, default=''):
|
||||||
|
csv_col = col_map.get(field)
|
||||||
|
if not csv_col:
|
||||||
|
return default
|
||||||
|
return (row.get(csv_col) or '').strip()
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2):
|
||||||
|
rows_parsed += 1
|
||||||
|
part_number = get_val(row, 'part_number')
|
||||||
|
name = get_val(row, 'name')
|
||||||
|
|
||||||
|
if not part_number and not name:
|
||||||
|
rows_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not part_number:
|
||||||
|
part_number = name[:50].upper().replace(' ', '-')
|
||||||
|
|
||||||
|
price_1 = parse_number(get_val(row, 'price'))
|
||||||
|
cost = parse_number(get_val(row, 'cost'))
|
||||||
|
stock = int(parse_number(get_val(row, 'stock')))
|
||||||
|
brand = get_val(row, 'brand')
|
||||||
|
price_2 = parse_number(get_val(row, 'price_2'))
|
||||||
|
price_3 = parse_number(get_val(row, 'price_3'))
|
||||||
|
category = get_val(row, 'category')
|
||||||
|
location = get_val(row, 'location') or 'Principal'
|
||||||
|
min_stock = int(parse_number(get_val(row, 'min_stock')))
|
||||||
|
unit = get_val(row, 'unit') or 'PZA'
|
||||||
|
barcode = get_val(row, 'barcode')
|
||||||
|
tax_rate = parse_number(get_val(row, 'tax_rate'), default=0.16)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
if rows_parsed <= 5:
|
||||||
|
print(f' [DRY] {part_number:20} {name[:30]:30} ${price_1:.2f} stock={stock} brand={brand}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# UPSERT by part_number within this branch
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO inventory
|
||||||
|
(part_number, name, brand, price_1, price_2, price_3,
|
||||||
|
cost, tax_rate, unit, category, location,
|
||||||
|
min_stock, barcode, branch_id, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, TRUE)
|
||||||
|
ON CONFLICT (part_number, branch_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
brand = EXCLUDED.brand,
|
||||||
|
price_1 = EXCLUDED.price_1,
|
||||||
|
price_2 = EXCLUDED.price_2,
|
||||||
|
price_3 = EXCLUDED.price_3,
|
||||||
|
cost = EXCLUDED.cost,
|
||||||
|
tax_rate = EXCLUDED.tax_rate,
|
||||||
|
unit = EXCLUDED.unit,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
location = EXCLUDED.location,
|
||||||
|
min_stock = EXCLUDED.min_stock,
|
||||||
|
barcode = EXCLUDED.barcode
|
||||||
|
RETURNING id, (xmax = 0) AS was_insert
|
||||||
|
""", (part_number, name, brand, price_1, price_2, price_3,
|
||||||
|
cost, tax_rate, unit, category, location,
|
||||||
|
min_stock, barcode, args.branch))
|
||||||
|
|
||||||
|
inv_id, was_insert = cur.fetchone()
|
||||||
|
|
||||||
|
if was_insert:
|
||||||
|
rows_inserted += 1
|
||||||
|
else:
|
||||||
|
rows_updated += 1
|
||||||
|
|
||||||
|
# Set initial stock via inventory_operations (append-only model)
|
||||||
|
if stock > 0 and was_insert:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO inventory_operations
|
||||||
|
(inventory_id, operation_type, quantity, reference, notes)
|
||||||
|
VALUES (%s, 'adjustment', %s, 'CSV Import', 'Carga inicial desde CSV')
|
||||||
|
""", (inv_id, stock))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f'Row {i}: {str(e)[:100]}'
|
||||||
|
errors.append(error_msg)
|
||||||
|
rows_skipped += 1
|
||||||
|
conn.rollback()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print()
|
||||||
|
print('╔══════════════════════════════════════════════╗')
|
||||||
|
print('║ IMPORT COMPLETE ║')
|
||||||
|
print('╚══════════════════════════════════════════════╝')
|
||||||
|
print(f' Parsed: {rows_parsed}')
|
||||||
|
print(f' Inserted: {rows_inserted}')
|
||||||
|
print(f' Updated: {rows_updated}')
|
||||||
|
print(f' Skipped: {rows_skipped}')
|
||||||
|
if errors:
|
||||||
|
print(f' Errors: {len(errors)}')
|
||||||
|
for e in errors[:10]:
|
||||||
|
print(f' • {e}')
|
||||||
|
if len(errors) > 10:
|
||||||
|
print(f' ... and {len(errors) - 10} more')
|
||||||
|
if args.dry_run:
|
||||||
|
print()
|
||||||
|
print(' (DRY RUN — nothing was written to the database)')
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
382
scripts/seed_marketplace.py
Normal file
382
scripts/seed_marketplace.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Marketplace seed script — populates the DB with realistic test data so you
|
||||||
|
can exercise the marketplace UI end-to-end from a browser.
|
||||||
|
|
||||||
|
What it creates:
|
||||||
|
- 3 bodegas in different cities (all pre-verified)
|
||||||
|
- ~60 warehouse_inventory rows spread across the 3 bodegas
|
||||||
|
- 1 new employee in tenant_refaccionaria_demo configured as seller for bodega #1
|
||||||
|
(PIN: 9999)
|
||||||
|
- 3 purchase orders in different states (submitted, confirmed, delivered)
|
||||||
|
|
||||||
|
After running, you can log into the POS:
|
||||||
|
- As buyer: Ivan Alcaraz (existing owner) — to test Explorar + Mis Pedidos
|
||||||
|
- As seller: "Bodega Test Seller" (new) PIN 9999 — to test Inbox + Inventario
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd /home/Autopartes/pos && python3 ../scripts/seed_marketplace.py
|
||||||
|
|
||||||
|
Safe to re-run: the script cleans up prior seed data before re-inserting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# Ensure we can import from the POS package
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||||
|
|
||||||
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
|
from services import marketplace_service as mkt
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# CONFIG — tweak these if you want different seed data
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
TENANT_ID = 11 # tenant_refaccionaria_demo
|
||||||
|
SELLER_PIN = '9999' # login PIN for the seed seller employee
|
||||||
|
SELLER_NAME = 'Bodega Test Seller'
|
||||||
|
SELLER_EMAIL = 'seller@bodegatest.mx'
|
||||||
|
|
||||||
|
BODEGAS = [
|
||||||
|
{
|
||||||
|
'name': 'Bodega Central Tijuana',
|
||||||
|
'owner_name': 'Juan Perez',
|
||||||
|
'whatsapp_phone': '5216641230001',
|
||||||
|
'email': 'juan@bodegacentral.mx',
|
||||||
|
'city': 'Tijuana',
|
||||||
|
'state': 'BC',
|
||||||
|
'address': 'Blvd. Industrial 1234, Col. Otay, Tijuana BC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Refacciones del Norte GDL',
|
||||||
|
'owner_name': 'Maria Gonzalez',
|
||||||
|
'whatsapp_phone': '5213311230002',
|
||||||
|
'email': 'maria@refnorte.mx',
|
||||||
|
'city': 'Guadalajara',
|
||||||
|
'state': 'JAL',
|
||||||
|
'address': 'Av. Vallarta 5678, Zapopan JAL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'AutoPartes del Bajio CDMX',
|
||||||
|
'owner_name': 'Carlos Torres',
|
||||||
|
'whatsapp_phone': '5215511230003',
|
||||||
|
'email': 'carlos@autopartesbajio.mx',
|
||||||
|
'city': 'Ciudad de Mexico',
|
||||||
|
'state': 'CDMX',
|
||||||
|
'address': 'Eje 5 Norte 910, Azcapotzalco CDMX',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# HELPERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f' → {msg}')
|
||||||
|
|
||||||
|
|
||||||
|
def hash_pin(pin: str) -> str:
|
||||||
|
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# CLEANUP — remove previous seed data before re-inserting
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def cleanup(master_conn, tenant_conn):
|
||||||
|
"""Remove previous seed data so re-running the script is idempotent."""
|
||||||
|
log('Cleaning up previous seed data...')
|
||||||
|
|
||||||
|
# Tenant side: remove the seed seller employee
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
cur.execute("DELETE FROM employees WHERE email = %s", (SELLER_EMAIL,))
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
# Master side: remove any bodegas with the seed names
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
names = tuple(b['name'] for b in BODEGAS)
|
||||||
|
# Cascade will clean up purchase_orders, po_items, po_status_history,
|
||||||
|
# and warehouse_inventory rows referencing these bodegas.
|
||||||
|
# warehouse_inventory's FK to bodegas is not CASCADE — handle manually.
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM warehouse_inventory
|
||||||
|
WHERE bodega_id IN (SELECT id_bodega FROM bodegas WHERE name = ANY(%s))
|
||||||
|
""", (list(names),))
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM purchase_orders
|
||||||
|
WHERE bodega_id IN (SELECT id_bodega FROM bodegas WHERE name = ANY(%s))
|
||||||
|
""", (list(names),))
|
||||||
|
cur.execute("DELETE FROM bodegas WHERE name = ANY(%s)", (list(names),))
|
||||||
|
master_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
log('Cleanup complete.')
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1 — Create bodegas (pre-verified)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def create_bodegas(master_conn) -> list[int]:
|
||||||
|
bodega_ids = []
|
||||||
|
for b in BODEGAS:
|
||||||
|
bid = mkt.create_bodega(
|
||||||
|
master_conn,
|
||||||
|
name=b['name'],
|
||||||
|
owner_name=b['owner_name'],
|
||||||
|
whatsapp_phone=b['whatsapp_phone'],
|
||||||
|
email=b['email'],
|
||||||
|
city=b['city'],
|
||||||
|
state=b['state'],
|
||||||
|
address=b['address'],
|
||||||
|
)
|
||||||
|
mkt.verify_bodega(master_conn, bid)
|
||||||
|
bodega_ids.append(bid)
|
||||||
|
log(f'Created+verified bodega #{bid}: {b["name"]}')
|
||||||
|
master_conn.commit()
|
||||||
|
return bodega_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2 — Populate warehouse_inventory
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def populate_inventory(master_conn, bodega_ids: list[int]):
|
||||||
|
"""Pick ~60 real OEM parts from the catalog, split across the 3 bodegas
|
||||||
|
with pseudo-realistic price and stock levels.
|
||||||
|
|
||||||
|
warehouse_inventory has an FK on user_id → users(id_user). We use a
|
||||||
|
single master user as the legacy owner AND differentiate bodegas via
|
||||||
|
a composite (user_id, part_id, warehouse_location) key where
|
||||||
|
warehouse_location becomes "Bodega#{bodega_id}" so different bodegas
|
||||||
|
can hold the same part without violating the UNIQUE constraint.
|
||||||
|
"""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
|
||||||
|
# Resolve a valid user_id for the FK (doesn't matter which — we use it
|
||||||
|
# as a plumbing placeholder only; the real ownership is via bodega_id).
|
||||||
|
cur.execute("SELECT id_user FROM users ORDER BY id_user LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
log('WARN: no users in master DB — cannot seed inventory')
|
||||||
|
return
|
||||||
|
owner_user_id = row[0]
|
||||||
|
|
||||||
|
# Grab 60 popular parts (prefer those with aftermarket equivalents so the
|
||||||
|
# buyer gets meaningful data when they open the detail view).
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id_part, p.oem_part_number, p.name_part
|
||||||
|
FROM parts p
|
||||||
|
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
WHERE p.oem_part_number IS NOT NULL
|
||||||
|
GROUP BY p.id_part, p.oem_part_number, p.name_part
|
||||||
|
ORDER BY COUNT(ap.id_aftermarket_parts) DESC
|
||||||
|
LIMIT 60
|
||||||
|
""")
|
||||||
|
parts = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
import random
|
||||||
|
random.seed(42)
|
||||||
|
|
||||||
|
rows_inserted = 0
|
||||||
|
for i, (part_id, oem, name) in enumerate(parts):
|
||||||
|
bodega_count = random.choice([1, 1, 2, 2, 3])
|
||||||
|
bodegas_for_this_part = random.sample(bodega_ids, bodega_count)
|
||||||
|
|
||||||
|
for bid in bodegas_for_this_part:
|
||||||
|
price = round(random.uniform(50, 3500), 2)
|
||||||
|
stock = random.randint(1, 20)
|
||||||
|
location = f'Bodega#{bid}' # unique per bodega → no conflict
|
||||||
|
|
||||||
|
# Each insert gets its own cursor + savepoint so one failure
|
||||||
|
# doesn't abort the whole transaction.
|
||||||
|
try:
|
||||||
|
cur2 = master_conn.cursor()
|
||||||
|
cur2.execute("""
|
||||||
|
INSERT INTO warehouse_inventory
|
||||||
|
(user_id, part_id, price, stock_quantity, min_order_quantity,
|
||||||
|
warehouse_location, bodega_id, currency, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, 1, %s, %s, 'MXN', NOW())
|
||||||
|
ON CONFLICT (user_id, part_id, warehouse_location) DO UPDATE
|
||||||
|
SET price = EXCLUDED.price,
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
bodega_id = EXCLUDED.bodega_id,
|
||||||
|
updated_at = NOW()
|
||||||
|
""", (owner_user_id, part_id, price, stock, location, bid))
|
||||||
|
master_conn.commit()
|
||||||
|
cur2.close()
|
||||||
|
rows_inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
master_conn.rollback()
|
||||||
|
if rows_inserted < 3: # only log the first few to avoid spam
|
||||||
|
print(f' inventory insert failed for part {part_id}: {str(e)[:120]}')
|
||||||
|
|
||||||
|
log(f'Populated {rows_inserted} warehouse_inventory rows across {len(bodega_ids)} bodegas')
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3 — Create a seller employee
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def create_seller_employee(tenant_conn, bodega_id: int) -> int:
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
pin_hash = hash_pin(SELLER_PIN)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO employees
|
||||||
|
(name, email, role, marketplace_role, bodega_id, is_active, pin, password_hash)
|
||||||
|
VALUES (%s, %s, 'employee', 'seller', %s, TRUE, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (SELLER_NAME, SELLER_EMAIL, bodega_id, pin_hash, pin_hash))
|
||||||
|
emp_id = cur.fetchone()[0]
|
||||||
|
tenant_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
log(f'Created seller employee #{emp_id} "{SELLER_NAME}" for bodega #{bodega_id} (PIN: {SELLER_PIN})')
|
||||||
|
return emp_id
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4 — Create sample POs in different states
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def create_sample_pos(master_conn, bodega_ids: list[int]):
|
||||||
|
"""Create 3 POs in states: submitted, confirmed, delivered."""
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id_part FROM parts p
|
||||||
|
JOIN warehouse_inventory wi ON wi.part_id = p.id_part
|
||||||
|
WHERE wi.bodega_id = %s
|
||||||
|
LIMIT 3
|
||||||
|
""", (bodega_ids[0],))
|
||||||
|
sample_parts = [row[0] for row in cur.fetchall()]
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if not sample_parts:
|
||||||
|
log('WARN: no sample parts for PO seed, skipping')
|
||||||
|
return
|
||||||
|
|
||||||
|
# PO 1 — submitted (waiting for seller to confirm)
|
||||||
|
po1 = mkt.create_po_draft(
|
||||||
|
master_conn,
|
||||||
|
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
|
||||||
|
buyer_display_name='Ivan Alcaraz',
|
||||||
|
buyer_phone='5216649998877',
|
||||||
|
buyer_email='ivan@demo.mx',
|
||||||
|
bodega_id=bodega_ids[0],
|
||||||
|
items=[
|
||||||
|
{'part_id': sample_parts[0], 'quantity': 2, 'unit_price': 250.00},
|
||||||
|
{'part_id': sample_parts[1], 'quantity': 1, 'unit_price': 850.00},
|
||||||
|
],
|
||||||
|
delivery_method='pickup',
|
||||||
|
buyer_notes='Urgente — cliente esperando',
|
||||||
|
)
|
||||||
|
mkt.transition_po(master_conn, po_id=po1, new_status='submitted',
|
||||||
|
actor_user_id=1, actor_kind='buyer', note='Enviado')
|
||||||
|
log(f'Created PO #{po1} in state: submitted')
|
||||||
|
|
||||||
|
# PO 2 — confirmed (seller accepted, preparing)
|
||||||
|
po2 = mkt.create_po_draft(
|
||||||
|
master_conn,
|
||||||
|
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
|
||||||
|
buyer_display_name='Ivan Alcaraz',
|
||||||
|
buyer_phone='5216649998877',
|
||||||
|
buyer_email='ivan@demo.mx',
|
||||||
|
bodega_id=bodega_ids[0],
|
||||||
|
items=[
|
||||||
|
{'part_id': sample_parts[2], 'quantity': 3, 'unit_price': 420.00},
|
||||||
|
],
|
||||||
|
delivery_method='delivery',
|
||||||
|
delivery_address='Refaccionaria Demo, Av. Revolucion 100, Tijuana',
|
||||||
|
buyer_notes='Entregar en la tarde',
|
||||||
|
)
|
||||||
|
mkt.transition_po(master_conn, po_id=po2, new_status='submitted',
|
||||||
|
actor_user_id=1, actor_kind='buyer')
|
||||||
|
mkt.transition_po(master_conn, po_id=po2, new_status='confirmed',
|
||||||
|
actor_user_id=99, actor_kind='seller',
|
||||||
|
note='Confirmado — preparando pedido')
|
||||||
|
log(f'Created PO #{po2} in state: confirmed')
|
||||||
|
|
||||||
|
# PO 3 — delivered (full happy path, closed)
|
||||||
|
po3 = mkt.create_po_draft(
|
||||||
|
master_conn,
|
||||||
|
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
|
||||||
|
buyer_display_name='Ivan Alcaraz',
|
||||||
|
buyer_phone='5216649998877',
|
||||||
|
buyer_email='ivan@demo.mx',
|
||||||
|
bodega_id=bodega_ids[1], # different bodega
|
||||||
|
items=[
|
||||||
|
{'part_id': sample_parts[0], 'quantity': 1, 'unit_price': 175.50},
|
||||||
|
],
|
||||||
|
delivery_method='pickup',
|
||||||
|
)
|
||||||
|
for new_state, kind in [('submitted', 'buyer'), ('confirmed', 'seller'),
|
||||||
|
('ready', 'seller'), ('delivered', 'buyer')]:
|
||||||
|
mkt.transition_po(master_conn, po_id=po3, new_status=new_state,
|
||||||
|
actor_user_id=1 if kind == 'buyer' else 99,
|
||||||
|
actor_kind=kind)
|
||||||
|
log(f'Created PO #{po3} in state: delivered')
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# MAIN
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print('╔══════════════════════════════════════════════╗')
|
||||||
|
print('║ Nexus Marketplace — Seed Script ║')
|
||||||
|
print('╚══════════════════════════════════════════════╝')
|
||||||
|
print()
|
||||||
|
|
||||||
|
master_conn = get_master_conn()
|
||||||
|
tenant_conn = get_tenant_conn(TENANT_ID)
|
||||||
|
try:
|
||||||
|
cleanup(master_conn, tenant_conn)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('STEP 1 — Create bodegas')
|
||||||
|
bodega_ids = create_bodegas(master_conn)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('STEP 2 — Populate warehouse_inventory')
|
||||||
|
populate_inventory(master_conn, bodega_ids)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('STEP 3 — Create seller employee')
|
||||||
|
seller_id = create_seller_employee(tenant_conn, bodega_ids[0])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('STEP 4 — Create sample POs')
|
||||||
|
create_sample_pos(master_conn, bodega_ids)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('╔══════════════════════════════════════════════╗')
|
||||||
|
print('║ DONE ║')
|
||||||
|
print('╚══════════════════════════════════════════════╝')
|
||||||
|
print()
|
||||||
|
print('Log in at http://localhost:5001/pos/login with:')
|
||||||
|
print()
|
||||||
|
print(f' BUYER (Ivan Alcaraz — existing owner)')
|
||||||
|
print(f' PIN: (your existing PIN)')
|
||||||
|
print(f' Test at: http://localhost:5001/pos/marketplace')
|
||||||
|
print(f' Should see: Explorar + Mis Pedidos tabs')
|
||||||
|
print()
|
||||||
|
print(f' SELLER ({SELLER_NAME})')
|
||||||
|
print(f' Email: {SELLER_EMAIL}')
|
||||||
|
print(f' PIN: {SELLER_PIN}')
|
||||||
|
print(f' Test at: http://localhost:5001/pos/marketplace')
|
||||||
|
print(f' Should see: Pedidos Recibidos + Mi Inventario + Explorar')
|
||||||
|
print()
|
||||||
|
print(f'Bodegas created: #{bodega_ids[0]} (Tijuana), '
|
||||||
|
f'#{bodega_ids[1]} (Guadalajara), #{bodega_ids[2]} (CDMX)')
|
||||||
|
finally:
|
||||||
|
master_conn.close()
|
||||||
|
tenant_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
225
scripts/setup_instance.sh
Executable file
225
scripts/setup_instance.sh
Executable file
@@ -0,0 +1,225 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Nexus Autoparts — Instance Setup Script
|
||||||
|
# Target: Raspberry Pi OS / Debian / Ubuntu
|
||||||
|
# Usage: sudo bash setup_instance.sh "Refaccionaria El Toro" "refac-eltoro"
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUSINESS_NAME="${1:-Refaccionaria Demo}"
|
||||||
|
INSTANCE_ID="${2:-refac-$(date +%s)}"
|
||||||
|
DB_PASSWORD="nexus_autoparts_2026"
|
||||||
|
DB_USER="nexus"
|
||||||
|
MASTER_DB="nexus_autoparts"
|
||||||
|
OWNER_PIN="${3:-1234}"
|
||||||
|
INSTALL_DIR="/home/Autopartes"
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════════════╗"
|
||||||
|
echo "║ Nexus Autoparts — Instance Setup ║"
|
||||||
|
echo "║ Business: $BUSINESS_NAME"
|
||||||
|
echo "║ ID: $INSTANCE_ID"
|
||||||
|
echo "╚══════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ─── 1. System deps ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Installing system dependencies..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq \
|
||||||
|
postgresql postgresql-client \
|
||||||
|
python3 python3-pip python3-venv python3-dev \
|
||||||
|
nodejs npm \
|
||||||
|
ffmpeg espeak-ng \
|
||||||
|
git curl wget \
|
||||||
|
libpq-dev gcc \
|
||||||
|
2>/dev/null
|
||||||
|
|
||||||
|
echo " ✓ System deps installed"
|
||||||
|
|
||||||
|
# ─── 2. Python deps ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Installing Python packages..."
|
||||||
|
pip3 install --break-system-packages --quiet \
|
||||||
|
flask psycopg2-binary sqlalchemy pyjwt bcrypt requests \
|
||||||
|
gunicorn faster-whisper \
|
||||||
|
2>/dev/null || pip3 install --quiet \
|
||||||
|
flask psycopg2-binary sqlalchemy pyjwt bcrypt requests \
|
||||||
|
gunicorn faster-whisper
|
||||||
|
|
||||||
|
echo " ✓ Python packages installed"
|
||||||
|
|
||||||
|
# ─── 3. Node.js deps (WhatsApp bridge) ───────────────────────────────
|
||||||
|
|
||||||
|
if [ -d "/opt/whatsapp-bridge" ]; then
|
||||||
|
echo "→ Installing WhatsApp bridge deps..."
|
||||||
|
cd /opt/whatsapp-bridge && npm install --silent 2>/dev/null
|
||||||
|
echo " ✓ WhatsApp bridge ready"
|
||||||
|
else
|
||||||
|
echo " ⚠ WhatsApp bridge not found at /opt/whatsapp-bridge — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. PostgreSQL setup ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Configuring PostgreSQL..."
|
||||||
|
systemctl enable postgresql
|
||||||
|
systemctl start postgresql
|
||||||
|
|
||||||
|
# Create user if doesn't exist
|
||||||
|
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\" | grep -q 1" \
|
||||||
|
|| su - postgres -c "createuser -s $DB_USER"
|
||||||
|
|
||||||
|
# Set password
|
||||||
|
su - postgres -c "psql -c \"ALTER USER $DB_USER WITH PASSWORD '$DB_PASSWORD';\""
|
||||||
|
|
||||||
|
# Create master DB if doesn't exist
|
||||||
|
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='$MASTER_DB'\" | grep -q 1" \
|
||||||
|
|| su - postgres -c "createdb -O $DB_USER $MASTER_DB"
|
||||||
|
|
||||||
|
echo " ✓ PostgreSQL configured"
|
||||||
|
|
||||||
|
# ─── 5. Import schema (master + tenant template) ─────────────────────
|
||||||
|
|
||||||
|
echo "→ Importing database schema..."
|
||||||
|
if [ -f "$INSTALL_DIR/vehicle_database/sql/schema.sql" ]; then
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB \
|
||||||
|
-f "$INSTALL_DIR/vehicle_database/sql/schema.sql" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$INSTALL_DIR/sql/marketplace_schema.sql" ]; then
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB \
|
||||||
|
-f "$INSTALL_DIR/sql/marketplace_schema.sql" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✓ Schema imported"
|
||||||
|
|
||||||
|
# ─── 6. Create tenant for this refaccionaria ─────────────────────────
|
||||||
|
|
||||||
|
echo "→ Creating tenant database for '$BUSINESS_NAME'..."
|
||||||
|
TENANT_DB="tenant_${INSTANCE_ID//-/_}"
|
||||||
|
|
||||||
|
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='$TENANT_DB'\" | grep -q 1" && {
|
||||||
|
echo " ⚠ Tenant DB '$TENANT_DB' already exists — skipping creation"
|
||||||
|
} || {
|
||||||
|
# Create from template if available, else create fresh
|
||||||
|
if su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='tenant_template'\" | grep -q 1"; then
|
||||||
|
su - postgres -c "createdb -O $DB_USER -T tenant_template $TENANT_DB"
|
||||||
|
echo " ✓ Created from tenant_template"
|
||||||
|
else
|
||||||
|
su - postgres -c "createdb -O $DB_USER $TENANT_DB"
|
||||||
|
echo " ✓ Created fresh (no template available)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply marketplace migration to tenant
|
||||||
|
if [ -f "$INSTALL_DIR/sql/marketplace_tenant_users.sql" ]; then
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" \
|
||||||
|
-f "$INSTALL_DIR/sql/marketplace_tenant_users.sql" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply plate_vehicles migration
|
||||||
|
if [ -f "$INSTALL_DIR/pos/migrations/v1.7_plates.sql" ]; then
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" \
|
||||||
|
-f "$INSTALL_DIR/pos/migrations/v1.7_plates.sql" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✓ Tenant DB ready: $TENANT_DB"
|
||||||
|
|
||||||
|
# ─── 7. Register tenant in master ────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Registering tenant in master DB..."
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB -c "
|
||||||
|
INSERT INTO tenants (name, db_name, is_active, subdomain)
|
||||||
|
VALUES ('$BUSINESS_NAME', '$TENANT_DB', true, '$INSTANCE_ID')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Get tenant ID
|
||||||
|
TENANT_ID=$(PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB -t -c "
|
||||||
|
SELECT id FROM tenants WHERE db_name = '$TENANT_DB' LIMIT 1;
|
||||||
|
" 2>/dev/null | tr -d ' ')
|
||||||
|
|
||||||
|
echo " ✓ Tenant registered: ID=$TENANT_ID"
|
||||||
|
|
||||||
|
# ─── 8. Create owner employee ────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Creating owner employee..."
|
||||||
|
PIN_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$OWNER_PIN'.encode(), bcrypt.gensalt()).decode())")
|
||||||
|
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" -c "
|
||||||
|
INSERT INTO employees (name, role, pin, password_hash, is_active, marketplace_role)
|
||||||
|
VALUES ('Administrador', 'owner', '$PIN_HASH', '$PIN_HASH', true, 'buyer')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO branches (name, address, phone, is_active)
|
||||||
|
VALUES ('Principal', '', '', true)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo " ✓ Owner employee created (PIN: $OWNER_PIN)"
|
||||||
|
|
||||||
|
# ─── 9. Configure peers.json ─────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Configuring peers.json..."
|
||||||
|
# Get this machine's IP
|
||||||
|
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
cat > "$INSTALL_DIR/pos/peers.json" << EOJSON
|
||||||
|
{
|
||||||
|
"instance_name": "$BUSINESS_NAME",
|
||||||
|
"instance_id": "$INSTANCE_ID",
|
||||||
|
"tenant_id": $TENANT_ID,
|
||||||
|
"peers": [],
|
||||||
|
"peer_timeout_seconds": 3,
|
||||||
|
"notes": "Add peer instances here: {\"name\": \"Refac B\", \"url\": \"http://192.168.X.Y:5001\", \"enabled\": true}"
|
||||||
|
}
|
||||||
|
EOJSON
|
||||||
|
|
||||||
|
echo " ✓ peers.json configured (local IP: $LOCAL_IP)"
|
||||||
|
|
||||||
|
# ─── 10. Create Gunicorn systemd service ─────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Setting up Gunicorn service..."
|
||||||
|
cat > /etc/systemd/system/nexus-pos.service << EOSERVICE
|
||||||
|
[Unit]
|
||||||
|
Description=Nexus Autoparts POS
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=$INSTALL_DIR/pos
|
||||||
|
Environment="PATH=/usr/local/bin:/usr/bin"
|
||||||
|
ExecStart=/usr/local/bin/gunicorn -w 2 -b 0.0.0.0:5001 --timeout 120 app:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOSERVICE
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nexus-pos
|
||||||
|
# Don't start yet — let user verify config first
|
||||||
|
|
||||||
|
echo " ✓ Gunicorn service created (nexus-pos)"
|
||||||
|
|
||||||
|
# ─── Done ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════╗"
|
||||||
|
echo "║ SETUP COMPLETE ║"
|
||||||
|
echo "╚══════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Instance: $BUSINESS_NAME"
|
||||||
|
echo "Tenant DB: $TENANT_DB (ID: $TENANT_ID)"
|
||||||
|
echo "Owner PIN: $OWNER_PIN"
|
||||||
|
echo "Local IP: $LOCAL_IP"
|
||||||
|
echo ""
|
||||||
|
echo "To start the POS:"
|
||||||
|
echo " systemctl start nexus-pos"
|
||||||
|
echo " # or manually: cd $INSTALL_DIR/pos && gunicorn -w 2 -b 0.0.0.0:5001 app:app"
|
||||||
|
echo ""
|
||||||
|
echo "To add peers, edit: $INSTALL_DIR/pos/peers.json"
|
||||||
|
echo "To import inventory: python3 $INSTALL_DIR/scripts/import_inventory.py --tenant=$TENANT_ID --csv=inventario.csv"
|
||||||
|
echo ""
|
||||||
|
echo "Access: http://$LOCAL_IP:5001/pos/login"
|
||||||
149
sql/marketplace_schema.sql
Normal file
149
sql/marketplace_schema.sql
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Nexus Marketplace B2B — Phase 1 schema
|
||||||
|
-- Target: nexus_autoparts (master DB)
|
||||||
|
-- Date: 2026-04-09
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
--
|
||||||
|
-- This migration is idempotent. Running it multiple times has no effect.
|
||||||
|
-- All new tables use IF NOT EXISTS and ALTERs use IF NOT EXISTS on columns.
|
||||||
|
--
|
||||||
|
-- Tables:
|
||||||
|
-- 1. bodegas — warehouse registry in the Nexus network
|
||||||
|
-- 2. purchase_orders — PO headers
|
||||||
|
-- 3. purchase_order_items — PO line items
|
||||||
|
-- 4. po_status_history — audit trail for PO state changes
|
||||||
|
--
|
||||||
|
-- Altered tables:
|
||||||
|
-- - warehouse_inventory (add bodega_id, currency, updated_at)
|
||||||
|
--
|
||||||
|
-- NOTE: the `users` table alter for marketplace_role + bodega_id lives in
|
||||||
|
-- the TENANT databases (each refaccionaria), not the master DB. That
|
||||||
|
-- migration is applied separately per-tenant via tenant_migrations.
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. BODEGAS — warehouse registry
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bodegas (
|
||||||
|
id_bodega SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
owner_name VARCHAR(200),
|
||||||
|
whatsapp_phone VARCHAR(20) NOT NULL,
|
||||||
|
email VARCHAR(200),
|
||||||
|
city VARCHAR(100),
|
||||||
|
state VARCHAR(50),
|
||||||
|
address TEXT,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMP,
|
||||||
|
commission_pct NUMERIC(5, 2) NOT NULL DEFAULT 0, -- reserved for Phase 3
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bodegas_verified ON bodegas (verified) WHERE verified = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bodegas_city ON bodegas (city);
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. WAREHOUSE_INVENTORY — extend existing table
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Existing schema has: id_inventory, user_id, part_id, price, stock_quantity,
|
||||||
|
-- min_order_quantity, warehouse_location, updated_at.
|
||||||
|
-- We add bodega_id (FK to the new table) and currency, keeping user_id as
|
||||||
|
-- the legacy owner pointer.
|
||||||
|
|
||||||
|
ALTER TABLE warehouse_inventory
|
||||||
|
ADD COLUMN IF NOT EXISTS bodega_id INTEGER REFERENCES bodegas(id_bodega),
|
||||||
|
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'MXN';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wi_bodega ON warehouse_inventory (bodega_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. PURCHASE_ORDERS — PO headers
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Status state machine:
|
||||||
|
-- draft → submitted → confirmed → ready → delivered → closed
|
||||||
|
-- ↘ rejected (terminal)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_orders (
|
||||||
|
id_po SERIAL PRIMARY KEY,
|
||||||
|
buyer_tenant_id INTEGER NOT NULL,
|
||||||
|
buyer_user_id INTEGER NOT NULL,
|
||||||
|
buyer_phone VARCHAR(20),
|
||||||
|
buyer_email VARCHAR(200),
|
||||||
|
buyer_display_name VARCHAR(200),
|
||||||
|
bodega_id INTEGER NOT NULL REFERENCES bodegas(id_bodega),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||||
|
total_amount NUMERIC(12, 2),
|
||||||
|
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||||
|
buyer_notes TEXT,
|
||||||
|
seller_notes TEXT,
|
||||||
|
delivery_method VARCHAR(50), -- 'pickup' | 'delivery'
|
||||||
|
delivery_address TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
submitted_at TIMESTAMP,
|
||||||
|
confirmed_at TIMESTAMP,
|
||||||
|
ready_at TIMESTAMP,
|
||||||
|
delivered_at TIMESTAMP,
|
||||||
|
closed_at TIMESTAMP,
|
||||||
|
CONSTRAINT chk_po_status CHECK (
|
||||||
|
status IN ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_buyer ON purchase_orders (buyer_tenant_id, buyer_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_bodega ON purchase_orders (bodega_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_status ON purchase_orders (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_created ON purchase_orders (created_at DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. PURCHASE_ORDER_ITEMS — line items
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_order_items (
|
||||||
|
id_po_item SERIAL PRIMARY KEY,
|
||||||
|
po_id INTEGER NOT NULL REFERENCES purchase_orders(id_po) ON DELETE CASCADE,
|
||||||
|
part_id INTEGER NOT NULL REFERENCES parts(id_part),
|
||||||
|
oem_part_number VARCHAR(100),
|
||||||
|
part_name VARCHAR(300),
|
||||||
|
manufacturer VARCHAR(200),
|
||||||
|
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||||
|
unit_price NUMERIC(12, 2),
|
||||||
|
subtotal NUMERIC(12, 2),
|
||||||
|
confirmed_qty INTEGER, -- bodega may adjust after confirmation
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_items_po ON purchase_order_items (po_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_items_part ON purchase_order_items (part_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. PO_STATUS_HISTORY — audit trail
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS po_status_history (
|
||||||
|
id_history SERIAL PRIMARY KEY,
|
||||||
|
po_id INTEGER NOT NULL REFERENCES purchase_orders(id_po) ON DELETE CASCADE,
|
||||||
|
from_status VARCHAR(20),
|
||||||
|
to_status VARCHAR(20) NOT NULL,
|
||||||
|
actor_user_id INTEGER,
|
||||||
|
actor_kind VARCHAR(20), -- 'buyer' | 'seller' | 'system' | 'admin'
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_history_po ON po_status_history (po_id, created_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Verification queries
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Run after applying to check everything landed:
|
||||||
|
-- SELECT COUNT(*) FROM bodegas;
|
||||||
|
-- SELECT COUNT(*) FROM purchase_orders;
|
||||||
|
-- SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'warehouse_inventory' AND column_name IN ('bodega_id', 'currency');
|
||||||
16
sql/marketplace_tenant_users.sql
Normal file
16
sql/marketplace_tenant_users.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Marketplace — per-tenant employees table migration
|
||||||
|
-- Apply to: tenant_template, tenant_refaccionaria_demo, tenant_acct_test,
|
||||||
|
-- and any future tenant DB.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE employees
|
||||||
|
ADD COLUMN IF NOT EXISTS marketplace_role VARCHAR(20) NOT NULL DEFAULT 'buyer',
|
||||||
|
ADD COLUMN IF NOT EXISTS bodega_id INTEGER;
|
||||||
|
|
||||||
|
-- Valid values: 'buyer' (refaccionaria/taller) | 'seller' (bodega) | 'admin'
|
||||||
|
-- bodega_id references master.bodegas(id_bodega). No FK because that table
|
||||||
|
-- lives in a different database — the app enforces referential integrity.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_marketplace_role ON employees (marketplace_role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_bodega ON employees (bodega_id) WHERE bodega_id IS NOT NULL;
|
||||||
Reference in New Issue
Block a user