commit f574cc297bd3ea5c73abd84d2c188dbfbab1d61c Author: admin.suherdy Date: Tue Dec 2 23:14:27 2025 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffe44b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,152 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Node modules (if using any JS tools) +node_modules/ + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ec5bc2 --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# POS Face Recognition + +## Overview + +This Odoo module extends the Point of Sale (POS) system with AI-powered face recognition capabilities. It enables automatic customer identification through facial recognition, providing staff with instant access to customer information, purchase history, and personalized recommendations. + +## Features + +### 🎯 Core Functionality + +- **Real-time Face Recognition**: Automatically identifies customers using webcam in the POS interface +- **Customer Training**: Capture and store up to 3 face images per customer for accurate recognition +- **Automatic Sync**: Face images are automatically synchronized with the AI server +- **Confidence Scoring**: Displays match probability for each recognized customer + +### 📊 Customer Insights + +When a customer is recognized, the sidebar displays: + +- **Customer Information**: Name and contact details +- **Last 2 Orders**: Complete order history including: + - Order number and date + - Total amount + - Order status (with color-coded badges) + - Complete product list with quantities and prices +- **Top 3 Products**: Most frequently purchased items by the customer + +### 🎨 User Interface + +- **Sidebar Integration**: Non-intrusive sidebar in the POS product screen +- **Live Camera Feed**: Real-time video preview for face recognition +- **Match List**: Shows all potential customer matches with confidence scores +- **Detailed Order Cards**: Beautifully styled order history with hover effects +- **Responsive Design**: Optimized for POS touchscreen interfaces + +## Requirements + +### Odoo Dependencies + +- `point_of_sale` +- `contacts` + +### External Dependencies + +- **AI Face Recognition Server**: A separate Python Flask server that handles face recognition + - Repository: `ai_face_recognition_server` (included in workspace) + - Required Python packages: `flask`, `face_recognition`, `numpy`, `opencv-python` + +### Hardware Requirements + +- Webcam or camera device +- Sufficient lighting for face recognition + +## Installation + +### 1. Install the Odoo Module + +```bash +# Copy the module to your Odoo addons directory +cp -r pos_face_recognition /path/to/odoo/addons/ + +# Update the addons list in Odoo +# Navigate to Apps > Update Apps List + +# Search for "POS Face Recognition" and install +``` + +### 2. Setup the AI Face Recognition Server + +```bash +# Navigate to the AI server directory +cd ai_face_recognition_server + +# Install Python dependencies +pip install -r requirements.txt + +# Start the server +python app.py +``` + +The server will run on `http://localhost:5000` by default. + +### 3. Configure the Module + +1. Go to **Point of Sale > Configuration > Point of Sale** +2. Select your POS configuration +3. In the **Face Recognition** section, set the **Face Recognition Server URL**: + ``` + http://localhost:5000 + ``` +4. Save the configuration + +## Usage + +### Training Customer Faces + +1. Navigate to **Contacts** +2. Open a customer record +3. In the **Face Recognition** tab, you'll find three image fields: + - **Face Image 1** + - **Face Image 2** + - **Face Image 3** +4. Upload clear, front-facing photos of the customer +5. Save the record - images are automatically synced to the AI server + +**Best Practices for Training Images:** +- Use well-lit, clear photos +- Capture different angles and expressions +- Ensure the face is clearly visible +- Avoid sunglasses or face coverings +- Use recent photos + +### Using Face Recognition in POS + +1. Open the POS session +2. The face recognition sidebar appears on the right side +3. The camera automatically starts and begins scanning for faces +4. When a customer is recognized: + - Their name appears in the match list with a confidence score + - Click on the match to select the customer + - View their order history and top products + - The customer is automatically set for the current order + +### Understanding the Display + +**Match List:** +- Shows all potential matches with confidence percentage +- Higher percentage = more confident match +- Click any match to select that customer + +**Order History:** +- Displays the last 2 orders with complete details +- Color-coded status badges: + - 🟢 Green: Paid/Done + - 🔵 Blue: Invoiced +- Shows all products ordered with quantities and prices + +**Top Products:** +- Highlights the 3 most frequently purchased items +- Helps staff make personalized recommendations + +## Configuration + +### POS Configuration + +**Settings > Point of Sale > Configuration > Point of Sale** + +- `pos_face_rec_server_url`: URL of the AI Face Recognition Server + +### Partner Configuration + +**Contacts > Customer** + +- `image_face_1`: First training image +- `image_face_2`: Second training image +- `image_face_3`: Third training image + +## Technical Details + +### Module Structure + +``` +pos_face_recognition/ +├── __init__.py +├── __manifest__.py +├── README.md +├── models/ +│ ├── __init__.py +│ ├── pos_config.py # POS configuration extension +│ ├── res_partner.py # Customer model with face images +│ └── res_config_settings.py # Settings configuration +├── views/ +│ ├── pos_config_views.xml +│ ├── res_partner_views.xml +│ └── res_config_settings_views.xml +└── static/ + └── src/ + ├── css/ + │ └── face_recognition.css + ├── js/ + │ ├── face_recognition_sidebar.js + │ ├── models.js + │ └── partner_details_edit.js + └── xml/ + ├── face_recognition_screens.xml + └── partner_details_edit.xml +``` + +### API Endpoints + +The module communicates with the AI server using these endpoints: + +**POST /train** +- Trains the AI model with customer face images +- Payload: `{ partner_id, name, images: [base64_image1, base64_image2, base64_image3] }` + +**POST /recognize** +- Recognizes faces in the provided image +- Payload: `{ image: base64_image }` +- Response: `{ matches: [{ id, name, probability }] }` + +### Data Flow + +1. **Training Phase:** + - Customer face images uploaded in Odoo + - Images automatically synced to AI server via `/train` endpoint + - AI server trains face recognition model + +2. **Recognition Phase:** + - POS camera captures frames every 5 seconds + - Frame sent to AI server via `/recognize` endpoint + - Server returns matching customers with confidence scores + - POS displays matches and fetches order history + +### Database Schema + +**res.partner (extended)** +```python +image_face_1 = fields.Binary("Face Image 1", attachment=True) +image_face_2 = fields.Binary("Face Image 2", attachment=True) +image_face_3 = fields.Binary("Face Image 3", attachment=True) +``` + +**pos.config (extended)** +```python +pos_face_rec_server_url = fields.Char("Face Recognition Server URL") +``` + +## Troubleshooting + +### Camera Not Working + +- **Check browser permissions**: Ensure the browser has camera access +- **HTTPS requirement**: Some browsers require HTTPS for camera access +- **Check console**: Open browser developer tools for error messages + +### No Matches Found + +- **Verify server connection**: Check if the AI server is running +- **Check server URL**: Ensure the URL in POS config is correct +- **Training data**: Verify customer has face images uploaded +- **Lighting**: Ensure adequate lighting for face detection + +### Images Not Syncing + +- **Check server URL**: Verify the Face Recognition Server URL is configured +- **Server logs**: Check AI server logs for errors +- **Network connectivity**: Ensure Odoo can reach the AI server +- **Image format**: Ensure images are in supported formats (JPEG, PNG) + +### Performance Issues + +- **Recognition interval**: Default is 5 seconds, can be adjusted in code +- **Image quality**: Lower resolution images process faster +- **Server resources**: Ensure AI server has adequate CPU/RAM + +## Development + +### Extending the Module + +**Add more customer insights:** +```python +# In res_partner.py +def get_customer_stats(self): + self.ensure_one() + # Add custom logic + return {...} +``` + +**Customize recognition interval:** +```javascript +// In face_recognition_sidebar.js +this.recognitionInterval = setInterval(() => { + this.captureAndSendFrame(); +}, 3000); // Change from 5000 to 3000 for 3 seconds +``` + +**Add more order history:** +```javascript +// In face_recognition_sidebar.js +const orders = await this.orm.searchRead("pos.order", + [['partner_id', '=', partnerId]], + ['name', 'date_order', 'amount_total', 'state', 'lines'], + { limit: 5, order: "date_order desc" } // Change from 2 to 5 +); +``` + +## Security & Privacy + +- Face images are stored securely in Odoo's attachment system +- Communication with AI server should use HTTPS in production +- Comply with local privacy laws (GDPR, CCPA, etc.) +- Obtain customer consent before capturing face images +- Implement data retention policies +- Provide customers with opt-out options + +## License + +LGPL-3 + +## Support + +For issues, questions, or contributions: +- Check the troubleshooting section +- Review server logs for detailed error messages +- Ensure all dependencies are properly installed + +## Changelog + +### Version 17.0.1.0.0 +- Initial release +- Real-time face recognition in POS +- Customer training with 3 images +- Last 2 orders display with detailed information +- Top 3 products recommendation +- Automatic image synchronization +- Confidence scoring for matches +- Responsive sidebar UI with enhanced styling + +## Credits + +Developed for Odoo 17.0 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..64fbcfc --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': 'POS Face Recognition', + 'version': '17.0.1.0.0', + 'category': 'Point of Sale', + 'summary': 'Face Recognition for POS', + 'description': """ + This module extends the Point of Sale to include Face Recognition capabilities. + - Capture customer images for training. + - Recognize customers in POS using AI. + """, + 'depends': ['point_of_sale', 'contacts'], + 'data': [ + 'views/res_partner_views.xml', + 'views/pos_config_views.xml', + 'views/res_config_settings_views.xml', + ], + 'assets': { + 'point_of_sale.assets_prod': [ + 'pos_face_recognition/static/src/xml/face_recognition_screens.xml', + 'pos_face_recognition/static/src/xml/partner_details_edit.xml', + 'pos_face_recognition/static/src/js/models.js', + 'pos_face_recognition/static/src/js/face_recognition_sidebar.js', + 'pos_face_recognition/static/src/js/partner_details_edit.js', + 'pos_face_recognition/static/src/css/face_recognition.css', + ], + }, + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/ai_face_recognition_server/app.py b/ai_face_recognition_server/app.py new file mode 100644 index 0000000..4a8c3c6 --- /dev/null +++ b/ai_face_recognition_server/app.py @@ -0,0 +1,170 @@ +from flask import Flask, request, jsonify +from flask_cors import CORS +import face_recognition +import numpy as np +import base64 +import cv2 +import os + +app = Flask(__name__) +CORS(app) # Enable CORS for all routes + +# In-memory storage for known face encodings and names +known_face_encodings = [] +known_face_names = [] +known_face_ids = [] + +def load_known_faces(): + """ + Load known faces from a directory or database. + For this MVP, we'll assume images are stored in a 'faces' directory + with filenames like 'partner_id_name.jpg'. + In a real production scenario, you'd fetch these from Odoo or a shared storage. + """ + global known_face_encodings, known_face_names, known_face_ids + + faces_dir = "faces" + if not os.path.exists(faces_dir): + os.makedirs(faces_dir) + print(f"Created {faces_dir} directory. Please add images there.") + return + + for filename in os.listdir(faces_dir): + if filename.endswith((".jpg", ".png", ".jpeg")): + try: + # Filename format: ID_Name.jpg + name_part = os.path.splitext(filename)[0] + parts = name_part.split('_', 1) + if len(parts) == 2: + p_id = int(parts[0]) + name = parts[1] + else: + p_id = 0 + name = name_part + + image_path = os.path.join(faces_dir, filename) + image = face_recognition.load_image_file(image_path) + encodings = face_recognition.face_encodings(image) + + if encodings: + known_face_encodings.append(encodings[0]) + known_face_names.append(name) + known_face_ids.append(p_id) + print(f"Loaded face for: {name} (ID: {p_id})") + except Exception as e: + print(f"Error loading {filename}: {e}") + +@app.route('/recognize', methods=['POST']) +def recognize_face(): + data = request.json + if 'image' not in data: + return jsonify({'error': 'No image provided'}), 400 + + try: + # Decode base64 image + image_data = base64.b64decode(data['image']) + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if frame is None: + return jsonify({'error': 'Invalid image data'}), 400 + + # Convert BGR (OpenCV) to RGB (face_recognition) + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Ensure the image is in the correct format + rgb_frame = np.ascontiguousarray(rgb_frame, dtype=np.uint8) + + # Find all faces in the current frame + face_locations = face_recognition.face_locations(rgb_frame) + face_encodings = face_recognition.face_encodings(rgb_frame, face_locations) + + matches_list = [] + + for face_encoding in face_encodings: + # See if the face is a match for the known face(s) + matches = face_recognition.compare_faces(known_face_encodings, face_encoding) + name = "Unknown" + p_id = 0 + probability = 0.0 + + # Or instead, use the known face with the smallest distance to the new face + face_distances = face_recognition.face_distance(known_face_encodings, face_encoding) + if len(face_distances) > 0: + best_match_index = np.argmin(face_distances) + if matches[best_match_index]: + name = known_face_names[best_match_index] + p_id = known_face_ids[best_match_index] + # Simple probability estimation based on distance (lower distance = higher prob) + # Distance 0.0 -> 100%, Distance 0.6 -> ~0% (threshold) + distance = face_distances[best_match_index] + probability = max(0, 1.0 - distance) + + matches_list.append({ + 'id': p_id, + 'name': name, + 'probability': probability + }) + + # Sort by probability + matches_list.sort(key=lambda x: x['probability'], reverse=True) + + return jsonify({'matches': matches_list}) + + except Exception as e: + print(f"Error processing image: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/train', methods=['POST']) +def train_face(): + data = request.json + if 'partner_id' not in data or 'images' not in data: + return jsonify({'error': 'Missing partner_id or images'}), 400 + + partner_id = data['partner_id'] + name = data.get('name', 'Unknown') + images = data['images'] # List of base64 strings + + faces_dir = "faces" + if not os.path.exists(faces_dir): + os.makedirs(faces_dir) + + processed_count = 0 + + # Clear existing images for this partner to avoid duplicates/stale data? + # For simplicity, we might just overwrite or append. Let's append with index. + # Or better, delete old ones first if we want to keep it clean. + # Let's just save new ones for now. + + for idx, img_data in enumerate(images): + if not img_data: + continue + + try: + # Decode base64 + image_bytes = base64.b64decode(img_data) + filename = f"{partner_id}_{name.replace(' ', '_')}_{idx}.jpg" + filepath = os.path.join(faces_dir, filename) + + with open(filepath, "wb") as f: + f.write(image_bytes) + + # Update in-memory knowledge + image = face_recognition.load_image_file(filepath) + encodings = face_recognition.face_encodings(image) + + if encodings: + known_face_encodings.append(encodings[0]) + known_face_names.append(name) + known_face_ids.append(partner_id) + processed_count += 1 + print(f"Trained face for: {name} (ID: {partner_id}) - Image {idx}") + + except Exception as e: + print(f"Error saving/training image {idx} for {name}: {e}") + + return jsonify({'message': f'Processed {processed_count} images for {name}'}) + +if __name__ == '__main__': + load_known_faces() + app.run(host='0.0.0.0', port=5000) diff --git a/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_0.jpg b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_0.jpg new file mode 100644 index 0000000..34e6a35 Binary files /dev/null and b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_0.jpg differ diff --git a/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_1.jpg b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_1.jpg new file mode 100644 index 0000000..2826207 Binary files /dev/null and b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_1.jpg differ diff --git a/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_2.jpg b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_2.jpg new file mode 100644 index 0000000..a734ba5 Binary files /dev/null and b/ai_face_recognition_server/faces/19_Suherdy_Yacob_58_2.jpg differ diff --git a/ai_face_recognition_server/requirements.txt b/ai_face_recognition_server/requirements.txt new file mode 100644 index 0000000..056574c --- /dev/null +++ b/ai_face_recognition_server/requirements.txt @@ -0,0 +1,5 @@ +flask +flask-cors +face_recognition +numpy +opencv-python diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d8f24b0 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_partner +from . import res_config_settings +from . import pos_config + diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..a622ee1 --- /dev/null +++ b/models/pos_config.py @@ -0,0 +1,10 @@ +from odoo import models, fields + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + pos_face_rec_server_url = fields.Char( + string="Face Recognition Server URL", + help="URL of the AI Face Recognition Server (e.g., http://localhost:5000)" + ) diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..2b05327 --- /dev/null +++ b/models/res_config_settings.py @@ -0,0 +1,12 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + pos_face_rec_server_url = fields.Char( + string="AI Face Recognition Server URL", + related='pos_config_id.pos_face_rec_server_url', + readonly=False, + help="URL of the AI Face Recognition Server (e.g., http://localhost:5000)" + ) diff --git a/models/res_partner.py b/models/res_partner.py new file mode 100644 index 0000000..34bf2b1 --- /dev/null +++ b/models/res_partner.py @@ -0,0 +1,74 @@ +from odoo import models, fields, api +import requests +import logging + +_logger = logging.getLogger(__name__) + +class ResPartner(models.Model): + _inherit = 'res.partner' + + image_face_1 = fields.Binary("Face Image 1", attachment=True) + image_face_2 = fields.Binary("Face Image 2", attachment=True) + image_face_3 = fields.Binary("Face Image 3", attachment=True) + + def _sync_face_images(self): + # Get the server URL from any POS config that has it configured + pos_config = self.env['pos.config'].search([('pos_face_rec_server_url', '!=', False)], limit=1) + if not pos_config or not pos_config.pos_face_rec_server_url: + _logger.warning("Face Recognition Server URL is not configured in any POS configuration") + return + + server_url = pos_config.pos_face_rec_server_url + + for partner in self: + # Only sync if at least one image is present + if not any([partner.image_face_1, partner.image_face_2, partner.image_face_3]): + continue + + images = [ + partner.image_face_1.decode('utf-8') if isinstance(partner.image_face_1, bytes) else partner.image_face_1, + partner.image_face_2.decode('utf-8') if isinstance(partner.image_face_2, bytes) else partner.image_face_2, + partner.image_face_3.decode('utf-8') if isinstance(partner.image_face_3, bytes) else partner.image_face_3 + ] + + payload = { + 'partner_id': partner.id, + 'name': partner.name, + 'images': images + } + + try: + # Remove trailing slash if present + base_url = server_url.rstrip('/') + requests.post(f"{base_url}/train", json=payload, timeout=5) + except Exception as e: + _logger.error(f"Failed to sync face images for partner {partner.id}: {e}") + + @api.model_create_multi + def create(self, vals_list): + partners = super().create(vals_list) + partners._sync_face_images() + return partners + + def write(self, vals): + res = super().write(vals) + if any(f in vals for f in ['image_face_1', 'image_face_2', 'image_face_3', 'name']): + self._sync_face_images() + return res + + def get_top_products(self): + self.ensure_one() + # Simple implementation: get top 3 products by quantity from past orders + query = """ + SELECT pt.name, sum(pol.qty) as qty + FROM pos_order_line pol + JOIN pos_order po ON pol.order_id = po.id + JOIN product_product pp ON pol.product_id = pp.id + JOIN product_template pt ON pp.product_tmpl_id = pt.id + WHERE po.partner_id = %s + GROUP BY pt.name + ORDER BY qty DESC + LIMIT 3 + """ + self.env.cr.execute(query, (self.id,)) + return [{'name': row[0], 'qty': row[1]} for row in self.env.cr.fetchall()] diff --git a/static/src/css/face_recognition.css b/static/src/css/face_recognition.css new file mode 100644 index 0000000..0ca1998 --- /dev/null +++ b/static/src/css/face_recognition.css @@ -0,0 +1,255 @@ +.face-recognition-sidebar { + position: absolute; + top: 65px; + right: 0; + bottom: 0; + width: 300px; + background: white; + border-left: 1px solid #ccc; + z-index: 100; + display: flex; + flex-direction: column; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); +} + +.sidebar-header { + padding: 10px; + background: #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #ccc; +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 10px; +} + +.camera-container video { + background: #000; + border-radius: 4px; +} + +.match-list { + list-style: none; + padding: 0; +} + +.match-item { + padding: 8px; + border-bottom: 1px solid #eee; + cursor: pointer; +} + +.match-item:hover { + background: #f9f9f9; +} + +.match-info { + display: flex; + justify-content: space-between; +} + +.customer-details { + margin-top: 20px; + border-top: 2px solid #eee; + padding-top: 10px; +} + +.close-btn { + background: none; + border: none; + font-size: 1.2em; + cursor: pointer; +} + +.face-recognition-sidebar .control-button { + cursor: pointer; +} + +.face-recognition-sidebar.hidden { + display: none; +} + +/* Add padding to ProductScreen to avoid sidebar overlap */ +.pos .product-screen { + padding-right: 305px; +} +/* Order +Details Styling */ +.last-orders { + margin-top: 15px; +} + +.last-orders h5 { + margin-bottom: 10px; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.orders-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.order-card { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 10px; + transition: box-shadow 0.2s; +} + +.order-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.order-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid #d0d0d0; +} + +.order-number { + font-weight: 600; + color: #2c3e50; + font-size: 13px; +} + +.order-date { + font-size: 11px; + color: #7f8c8d; +} + +.order-details { + margin-bottom: 8px; +} + +.order-row { + display: flex; + justify-content: space-between; + padding: 3px 0; + font-size: 12px; +} + +.order-row .label { + color: #666; + font-weight: 500; +} + +.order-row .value { + color: #333; + font-weight: 600; +} + +.order-row .value.status { + text-transform: capitalize; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; +} + +.order-row .value.status.paid { + background: #d4edda; + color: #155724; +} + +.order-row .value.status.done { + background: #d4edda; + color: #155724; +} + +.order-row .value.status.invoiced { + background: #cce5ff; + color: #004085; +} + +.order-products { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed #d0d0d0; +} + +.products-label { + font-size: 11px; + color: #666; + font-weight: 600; + margin-bottom: 5px; +} + +.product-list { + list-style: none; + padding: 0; + margin: 0; +} + +.product-item { + display: flex; + align-items: center; + padding: 4px 0; + font-size: 11px; + gap: 6px; +} + +.product-qty { + background: #007bff; + color: white; + padding: 2px 6px; + border-radius: 3px; + font-weight: 600; + min-width: 30px; + text-align: center; +} + +.product-name { + flex: 1; + color: #333; +} + +.product-price { + color: #28a745; + font-weight: 600; +} + +.no-orders { + color: #999; + font-style: italic; + font-size: 12px; + text-align: center; + padding: 20px 0; +} + +/* Top Products Styling */ +.top-products { + margin-top: 15px; + padding-top: 15px; + border-top: 2px solid #e0e0e0; +} + +.top-products h5 { + margin-bottom: 10px; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.top-products ul { + list-style: none; + padding: 0; +} + +.top-products li { + padding: 6px 8px; + background: #fff3cd; + border-left: 3px solid #ffc107; + margin-bottom: 6px; + font-size: 12px; + border-radius: 3px; +} diff --git a/static/src/js/face_recognition_sidebar.js b/static/src/js/face_recognition_sidebar.js new file mode 100644 index 0000000..46061aa --- /dev/null +++ b/static/src/js/face_recognition_sidebar.js @@ -0,0 +1,184 @@ +/** @odoo-module */ + +import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; +import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; + +export class FaceRecognitionSidebar extends Component { + static template = "pos_face_recognition.FaceRecognitionSidebar"; + + setup() { + this.pos = useService("pos"); + this.orm = useService("orm"); + console.log("FaceRecognitionSidebar setup"); + this.state = useState({ + matches: [], + selectedCustomer: null, + lastOrders: [], + topProducts: [], + }); + this.recognitionInterval = null; + this.stream = null; + + onMounted(() => { + this.startCamera(); + }); + + // Cleanup interval and camera when component is destroyed + onWillUnmount(() => { + if (this.recognitionInterval) { + clearInterval(this.recognitionInterval); + } + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + } + }); + } + + async startCamera() { + console.log("startCamera called"); + try { + const video = document.getElementById('face-rec-video'); + console.log("Video element found:", video); + if (video) { + this.stream = await navigator.mediaDevices.getUserMedia({ video: true }); + console.log("Camera stream obtained:", this.stream); + video.srcObject = this.stream; + + // Start real-time recognition loop once camera is ready + this.startRealTimeRecognition(); + } else { + console.error("Video element not found!"); + } + } catch (err) { + console.error("Error accessing camera:", err); + } + } + + startRealTimeRecognition() { + console.log("startRealTimeRecognition called"); + console.log("Server URL:", this.pos.config.pos_face_rec_server_url); + + if (!this.pos.config.pos_face_rec_server_url) { + console.error("Face Recognition Server URL is not configured. Please configure it in POS Settings."); + return; + } + + // Capture frame every 5 seconds + this.recognitionInterval = setInterval(() => { + console.log("Recognition interval tick"); + this.captureAndSendFrame(); + }, 5000); + } + + async captureAndSendFrame() { + const video = document.getElementById('face-rec-video'); + if (!video) { + console.error("Video element not found"); + return; + } + + if (!this.pos.config.pos_face_rec_server_url) { + console.error("Face Recognition Server URL is not configured"); + return; + } + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + // Convert to base64, remove header + const imageData = canvas.toDataURL('image/jpeg').split(',')[1]; + + try { + const response = await fetch(this.pos.config.pos_face_rec_server_url + '/recognize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ image: imageData }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.matches && result.matches.length > 0) { + // Enrich matches with partner phone numbers + this.state.matches = result.matches.map(match => { + const partner = this.pos.db.get_partner_by_id(match.id); + return { + ...match, + phone: partner ? partner.phone || partner.mobile : null + }; + }); + } + } + } catch (err) { + console.error("Error sending frame to AI server:", err); + } + } + + async selectCustomer(match) { + const partner = this.pos.db.get_partner_by_id(match.id); + if (partner) { + this.state.selectedCustomer = partner; + this.pos.get_order().set_partner(partner); + + // Fetch details + await this.fetchCustomerDetails(partner.id); + } + } + + async fetchCustomerDetails(partnerId) { + // Fetch last 2 orders with detailed information + const orders = await this.orm.searchRead("pos.order", + [['partner_id', '=', partnerId]], + ['name', 'date_order', 'amount_total', 'state', 'lines'], + { limit: 2, order: "date_order desc" } + ); + + // Fetch order lines for each order + for (let order of orders) { + if (order.lines && order.lines.length > 0) { + const lines = await this.orm.searchRead("pos.order.line", + [['id', 'in', order.lines]], + ['product_id', 'qty', 'price_subtotal_incl', 'full_product_name'], + {} + ); + // Add product names to lines + order.lines = lines.map(line => ({ + ...line, + product_name: line.full_product_name || (line.product_id && line.product_id[1]) || 'Unknown Product' + })); + } else { + order.lines = []; + } + } + + this.state.lastOrders = orders; + + // Fetch top 3 products + try { + const topProducts = await this.orm.call("res.partner", "get_top_products", [partnerId]); + console.log("Top products received:", topProducts); + console.log("Type:", typeof topProducts); + console.log("First product:", topProducts && topProducts[0]); + + // Ensure it's an array + if (Array.isArray(topProducts)) { + this.state.topProducts = topProducts; + } else { + console.error("topProducts is not an array:", topProducts); + this.state.topProducts = []; + } + } catch (error) { + console.error("Error fetching top products:", error); + this.state.topProducts = []; + } + } +} + +ProductScreen.components = { + ...ProductScreen.components, + FaceRecognitionSidebar, +}; diff --git a/static/src/js/models.js b/static/src/js/models.js new file mode 100644 index 0000000..f985632 --- /dev/null +++ b/static/src/js/models.js @@ -0,0 +1,20 @@ +/** @odoo-module */ + +import { PosStore } from "@point_of_sale/app/store/pos_store"; +import { patch } from "@web/core/utils/patch"; + +patch(PosStore.prototype, { + async _processData(loadedData) { + await super._processData(...arguments); + // We don't necessarily need to load the full image data for all partners on load + // because it would be heavy. We only need to send it back when saving. + // However, if we want to show existing images in the edit screen, we might need them. + // But standard Odoo doesn't load full images for partners in POS to save bandwidth. + // It usually loads small avatars. + // For now, let's NOT load them by default to avoid performance issues. + // The edit screen will show empty if not modified in current session, + // or we could fetch them on demand (like `partnerImageUrl` does). + // BUT, `saveChanges` in `partner_editor.js` sends `processedChanges`. + // If we want to support editing, we need to handle the fields. + } +}); diff --git a/static/src/js/partner_details_edit.js b/static/src/js/partner_details_edit.js new file mode 100644 index 0000000..e0874e7 --- /dev/null +++ b/static/src/js/partner_details_edit.js @@ -0,0 +1,107 @@ +/** @odoo-module */ + +import { PartnerDetailsEdit } from "@point_of_sale/app/screens/partner_list/partner_editor/partner_editor"; +import { patch } from "@web/core/utils/patch"; +import { getDataURLFromFile } from "@web/core/utils/urls"; +import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup"; +import { _t } from "@web/core/l10n/translation"; +import { loadImage } from "@point_of_sale/utils"; +import { useRef, useState, onWillUnmount } from "@odoo/owl"; + +patch(PartnerDetailsEdit.prototype, { + setup() { + super.setup(...arguments); + this.changes.image_face_1 = this.changes.image_face_1 || false; + this.changes.image_face_2 = this.changes.image_face_2 || false; + this.changes.image_face_3 = this.changes.image_face_3 || false; + + this.camera = useState({ + activeField: null, + }); + this.videoRef = useRef("video"); + + onWillUnmount(() => { + this.stopCamera(); + }); + }, + + async startCamera(fieldName) { + if (this.camera.activeField) { + this.stopCamera(); + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + this.camera.activeField = fieldName; + this.stream = stream; + // Wait for the video element to be rendered + await Promise.resolve(); + // We might need a small delay or rely on the ref being attached on render + // Since we set state, a render will happen. The ref should be available after render. + // However, in Owl, refs are usually available after patch. + // Let's try setting it in a next tick or just relying on the template to use the ref. + // Actually, we need to set srcObject. + setTimeout(() => { + if (this.videoRef.el) { + this.videoRef.el.srcObject = stream; + } + }, 100); + } catch (err) { + console.error("Camera error", err); + this.popup.add(ErrorPopup, { title: _t("Camera Error"), body: _t("Could not access camera.") }); + } + }, + + stopCamera() { + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + this.camera.activeField = null; + }, + + capturePhoto() { + if (!this.videoRef.el) return; + const video = this.videoRef.el; + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(video, 0, 0); + + const dataUrl = canvas.toDataURL('image/jpeg'); + // Remove data:image/jpeg;base64, prefix + this.changes[this.camera.activeField] = dataUrl.split(',')[1]; + this.stopCamera(); + }, + + async uploadFaceImage(event, fieldName) { + const file = event.target.files[0]; + if (!file) return; + + if (!file.type.match(/image.*/)) { + await this.popup.add(ErrorPopup, { + title: _t("Unsupported File Format"), + body: _t("Only web-compatible Image formats such as .png or .jpeg are supported."), + }); + return; + } + + const imageUrl = await getDataURLFromFile(file); + const loadedImage = await loadImage(imageUrl, { + onError: () => { + this.popup.add(ErrorPopup, { + title: _t("Loading Image Error"), + body: _t("Encountered error when loading image. Please try again."), + }); + } + }); + + if (loadedImage) { + // Resize to reasonable size for face recognition (e.g. 800x600 is usually enough) + const resizedImage = await this._resizeImage(loadedImage, 800, 600); + const dataUrl = resizedImage.toDataURL(); + // Remove data:image/png;base64, prefix + this.changes[fieldName] = dataUrl.split(',')[1]; + } + } +}); diff --git a/static/src/xml/face_recognition_screens.xml b/static/src/xml/face_recognition_screens.xml new file mode 100644 index 0000000..f511724 --- /dev/null +++ b/static/src/xml/face_recognition_screens.xml @@ -0,0 +1,104 @@ + + + + +
+ + Face Rec. +
+
+ + +
+ + +
+
+ + + + + + + +
diff --git a/static/src/xml/partner_details_edit.xml b/static/src/xml/partner_details_edit.xml new file mode 100644 index 0000000..7bca71d --- /dev/null +++ b/static/src/xml/partner_details_edit.xml @@ -0,0 +1,79 @@ + + + + +
+ +
+ +
+
+
+ +
+ + + + +
+
+
+
+
+ +
+ +
+
+
+ +
+ + + + +
+
+
+
+
+ +
+ +
+
+
+ +
+ + + + +
+
+
+
+
+
+
diff --git a/views/pos_config_views.xml b/views/pos_config_views.xml new file mode 100644 index 0000000..c3ee4ed --- /dev/null +++ b/views/pos_config_views.xml @@ -0,0 +1,15 @@ + + + + pos.config.view.form.inherit.face.recognition + pos.config + + + + + + + + + + diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml new file mode 100644 index 0000000..8419d30 --- /dev/null +++ b/views/res_config_settings_views.xml @@ -0,0 +1,15 @@ + + + + res.config.settings.view.form.inherit.pos.face.recognition + res.config.settings + + + + + + + + + + diff --git a/views/res_partner_views.xml b/views/res_partner_views.xml new file mode 100644 index 0000000..833f44a --- /dev/null +++ b/views/res_partner_views.xml @@ -0,0 +1,21 @@ + + + + res.partner.form.face.recognition + res.partner + + + + + + + + + + + + + + + +