116 Commits

Author SHA1 Message Date
5704980dbd modified: templates/sales.html 2026-06-22 15:52:25 -04:00
10ba8d6f94 modified: templates/macros/modals.html 2026-06-22 13:49:01 -04:00
ed6eac8bac modified: templates/checkout.html 2026-06-22 13:40:53 -04:00
4fd6feeea4 modified: templates/checkout.html 2026-06-22 13:02:21 -04:00
4fd2e9fe43 Fixed some shit making buttons double press sometimes 2026-06-22 12:21:50 -04:00
9675a0f9c2 Added constellation background to login page and adjusted styling for better readability. 2026-05-30 03:50:18 -04:00
780136915f modified: templates/inventory.html 2026-05-21 01:25:11 -04:00
f6cd20f4fc deleted: core/db/.gitignore 2026-05-21 00:41:31 -04:00
24d408943d modified: app.py 2026-05-21 00:26:57 -04:00
7723255a90 modified: requirements.txt 2026-05-21 00:25:02 -04:00
7a2b34ac0b modified: requirements.txt 2026-05-21 00:11:04 -04:00
544444accf modified: requirements.txt 2026-05-21 00:09:49 -04:00
a5babd8131 modified: Dockerfile
modified:   README.md
	modified:   app.py
	new file:   blueprints/__init__.py
	new file:   blueprints/__pycache__/.gitignore
	new file:   blueprints/auth.py
	new file:   blueprints/finance.py
	new file:   blueprints/inventory.py
	new file:   blueprints/pos.py
	new file:   blueprints/sales.py
	new file:   core/__pycache__/.gitignore
	new file:   core/db.py
	new file:   core/db/.gitignore
	new file:   core/events.py
	new file:   core/openfood.py
	new file:   core/utils.py
	modified:   static/style.css
	modified:   templates/checkout.html
	modified:   templates/dicom.html
	modified:   templates/login.html
	modified:   templates/macros/base.html
	modified:   templates/macros/modals.html
	modified:   templates/macros/navbar.html
2026-05-21 00:05:31 -04:00
c2373c3ed6 modified: app.py 2026-05-15 05:12:06 -04:00
33bc739e12 modified: app.py
modified:   templates/macros/base.html
	modified:   templates/macros/modals.html
2026-05-15 04:04:41 -04:00
caf73ce156 modified: README.md
modified:   app.py
2026-04-17 16:24:24 -04:00
83f9f606de modified: templates/checkout.html
modified:   templates/macros/modals.html
2026-04-16 02:04:14 -04:00
4b3ef3eb8b Spoof sii legal info 2026-04-16 01:44:53 -04:00
656d1bb895 modified: README.md 2026-04-16 00:23:08 -04:00
c0a737915e modified: README.md 2026-04-16 00:21:46 -04:00
b9bcd49a0c Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-04-15 22:58:27 -04:00
47cc480cf5 feat: add expenses module, restaurant mode, and dynamic sales filters
- Gastos (Expenses): Added `/gastos` route, auto-creation of `expenses` DB table, and `gastos.html` to track net profit with split month/year dropdowns.
- Sales & Filters: Overhauled `/sales` backend to use pagination. Top summary cards now accurately reflect the selected payment method filter.
- Checkout Improvements:
  - Added "Transferencia" as a payment option with numpad shortcuts.
  - Built a "Pinned Products" quick-access grid using localStorage.
  - Implemented a global processing lock to prevent duplicate sales on double-clicks.
  - Burned the default HTML number arrows with custom CSS.
- Global Settings & Receipts:
  - Created a global settings modal accessible from the navbar.
  - Added localStorage toggles for custom business name and auto-print.
  - Added "Restaurant Mode" toggle to prompt for Client Name and Pickup Time, which now dynamically prints on the receipt.
- Bug Fixes: Resolved Jinja `TemplateSyntaxError` crash and removed the duplicate search bar in the checkout view.
2026-04-15 22:58:12 -04:00
7f4b23efda more rp2040 stuff 2026-04-04 01:10:19 -03:00
b2bc0801f5 Added numpad shortcuts at checkout 2026-03-30 21:14:47 -03:00
cc6fc28c4a new file: static/doom2/DOOM.EXE
new file:   static/doom2/DOOM.WAD
2026-03-18 01:53:52 -03:00
89f93f2638 Cleanup and Doom easter egg 2026-03-18 01:47:19 -03:00
53e28b15d9 Added Doom 2026-03-18 00:59:55 -03:00
1e44efda5e fallback VID PID + hex json + RE info, keypad WIP 2026-03-18 00:57:38 -03:00
48632ae058 numpad kinda working 2026-03-17 22:45:40 -03:00
712294fc2e SDI10809 RE success 2026-03-17 19:10:04 -03:00
a417715ff4 removed pdf asset 2026-03-17 17:38:48 -03:00
ea3633518a rename golang to go 2026-03-17 17:25:47 -03:00
0fcb8ce473 moved stuff + scanner com/hid edits 2026-03-17 17:25:14 -03:00
9cf0792866 cli mode 2026-03-17 16:15:18 -03:00
719e227ba4 golang scanner imp 2026-03-17 16:13:57 -03:00
ace45b1cc9 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-03-17 16:06:53 -03:00
0bd47658e9 hid scanner support 2026-03-17 16:06:22 -03:00
97a592b0c9 goscan fixes 2026-03-17 14:53:41 -03:00
4bbbb0334c WIP scale fw 2026-03-16 18:27:02 -03:00
27d5fb26d9 reorder meh 2026-03-13 23:39:38 -03:00
5119d4bb31 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-03-13 21:55:23 -03:00
acaf537f11 reverese engeneering of TM1621 display 2026-03-13 21:54:45 -03:00
55ff314163 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-03-13 18:07:54 -03:00
376b8c54a6 Version bump to v2.0, updated checkout and dicom 2026-03-13 18:07:09 -03:00
1a048a0e07 Fixed recepit lenght 2026-03-11 16:52:17 -03:00
77fc5920a2 image export like db 2026-03-11 15:59:50 -03:00
78d48db9ea added image_url column to dicom table 2026-03-10 23:39:44 -03:00
92e3a3f0f9 dicom evidence update 2026-03-10 23:22:50 -03:00
57cb27f6cf db dump tool 2026-03-10 22:47:40 -03:00
c24dae9694 actions button position fix 2026-03-10 22:41:34 -03:00
e0ac23a8e0 re-print old receipt from sales page 2026-03-10 22:37:47 -03:00
c1a06dc44c allow for inmediate confimation of checkout 2026-03-10 22:32:37 -03:00
72f6e0c822 receipt print fix 2026-03-10 22:25:09 -03:00
b2418d8c7e style fixes 2026-03-10 22:15:18 -03:00
9cb057668b login style fix 2026-03-10 21:41:15 -03:00
cb2aa89b16 WIP rewrite with macros 2026-03-10 20:15:58 -03:00
ef9a9296dd initial modularization + templates 2026-03-10 18:40:47 -03:00
3c4b2e148d Implemented Dicom 2026-03-09 18:25:49 -03:00
bf1bc84cd0 Checkout UI fixes 2026-03-09 16:54:55 -03:00
8e37f9e776 Implemented change calculator and various UI fixes 2026-03-09 16:32:23 -03:00
216abc8ad2 Fixed receipt design 2026-03-09 13:02:00 -03:00
cffa3d789b modified: templates/checkout.html 2026-03-09 11:34:49 -03:00
d7ef1573e5 modified: templates/checkout.html 2026-03-09 10:20:59 -03:00
e101833c7d modified: templates/checkout.html 2026-03-09 09:53:30 -03:00
6c98919c80 Added option forcustom product during checkout 2026-03-09 07:00:47 -03:00
cae35a266f Implemented receipt generation and printing 2026-03-09 06:20:24 -03:00
c57e8ab6db Implemented sales dashboard 2026-03-09 05:58:24 -03:00
135b14adcf Added persistent cart for chechout 2026-03-09 05:06:25 -03:00
9f59e122ef Various chechout improvements: subtotal rounding, placeholder color, and weight input now in grams for better UX. 2026-03-09 03:53:28 -03:00
43cc2a3caa Fixed UI inconsistencies and added logic to handle unit types in the product list and form. Also added a call to toggle the stock input visibility on page load based on the default unit type. 2026-03-09 02:55:07 -03:00
shironeko
2f2998b0fd esp attempt, stock + unit type 2026-03-07 19:21:14 -03:00
788b67804e oops 2026-03-07 03:35:22 -03:00
2bb38570f9 deactivated MP stuff 2026-03-07 03:23:00 -03:00
aacbce2557 oops 2026-03-07 03:07:55 -03:00
6c5085093d chekout update, mp study v1 2026-03-07 03:07:45 -03:00
423d563cc0 image compression, initial checkout(? 2026-03-04 22:47:02 -03:00
5e79b6938c delimiter character setup + extended api data 2026-03-01 03:54:05 -03:00
dcd14f1021 Scanner with LF thing to prevent partial scanning 2026-03-01 03:45:37 -03:00
b4344361e4 adding data to scan request + Go ARMv7 2026-03-01 03:23:53 -03:00
fcb75cb5a4 settings file for scanner 2026-03-01 02:21:17 -03:00
676f299796 version bump 2026-02-28 22:41:33 -03:00
751c77cac5 zoom + flash + focus = scanning improvements for iphone and android 2026-02-28 22:39:57 -03:00
85b2c0b4db more theme and camera fixes TwT 2026-02-27 01:11:54 -03:00
741690b30e X fix 2026-02-27 01:06:42 -03:00
7235c7ff09 theme and camera cookie 2026-02-27 00:57:45 -03:00
8cba7937c3 image cache fix 2026-02-27 00:29:25 -03:00
4779452acd image timestamp fix 2026-02-27 00:16:43 -03:00
600df52b04 camera index? 2026-02-27 00:03:55 -03:00
b1b99bc887 webui camera barcode scanner 2026-02-26 23:59:22 -03:00
c7c0b3feb2 picture qol fixes 2026-02-26 23:55:49 -03:00
184f2722bf sort table 2026-02-26 23:38:55 -03:00
0f9966d224 qol and scan fixes 2026-02-26 23:29:45 -03:00
43b2a9e2d5 version bump :p 2026-02-26 22:53:44 -03:00
3a39cb95db re-retriede image if cached one doesnt exist anymore 2026-02-26 22:52:34 -03:00
81cacd3589 bulk delete + more prompts 2026-02-26 22:50:20 -03:00
1f521ec1d2 colors and prompts fix 2026-02-26 22:33:38 -03:00
0dcf0bc930 modified: static/styleIndex.css
modified:   templates/index.html
2026-02-26 21:46:42 -03:00
df4ff9171d Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 21:16:41 -03:00
1b2e63bc86 added bulk price update feature, allowing users to select multiple products and apply a new price to all of them at once. The bulk action bar now shows the count of selected items and enables the "OK" button only when at least one product is selected. 2026-02-26 21:16:16 -03:00
80bf539484 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 20:48:56 -03:00
13bba33c26 modified: templates/index.html 2026-02-26 04:21:20 -03:00
ecd98c72ce new file: ScannerPython/output/scannerV2.exe
new file:   ScannerPython/scannerV2.py
2026-02-26 04:10:48 -03:00
344229b77b bump version 2026-02-26 03:35:34 -03:00
9a28daa2cd fill edit fileds on scan 2026-02-26 03:33:45 -03:00
ebf8bc72aa fix custom images cache 2026-02-26 03:30:23 -03:00
2ef510358d cache image not being used fixed 2026-02-26 03:22:05 -03:00
d492905e57 todos 2026-02-26 03:06:07 -03:00
7a4d122976 file organization of htmls css 2026-02-26 03:02:03 -03:00
8cc5138888 discord theme, top bar fix, organized folders 2026-02-26 02:55:20 -03:00
3f47b3cda4 new file: KeyGenerator/A4 Printable Grid.py
new file:   keychain_3x3_perfect.pdf
2026-02-26 02:39:05 -03:00
c1045b4878 path python fix 2026-02-26 02:37:07 -03:00
70c14acaa5 dark mode + reorganization 2026-02-26 02:29:23 -03:00
2701dfbf85 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 02:26:09 -03:00
6aa3421f0c tajetitas 2026-02-26 02:25:48 -03:00
b6cc945adb Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 02:00:58 -03:00
a22d808b7b modified: README.md 2026-02-26 01:59:16 -03:00
103 changed files with 8550 additions and 572 deletions

3
.gitignore vendored
View File

@@ -1,2 +1 @@
pos_database.db
ScannerGO/ScannerGO-*
.env

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Build SekiPOS (F5)",
"type": "node",
"request": "launch",
"preLaunchTask": "build-sekipos"
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system"
}

15
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-sekipos",
"type": "shell",
"command": "docker build -t sekipos:latest .",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
}
]
}

View File

@@ -2,21 +2,25 @@ FROM python:3.11-slim
WORKDIR /app
# Install dependencies
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
COPY . .
# Create the folder structure for the volume mounts
RUN mkdir -p /app/static/cache
# Create necessary directories
RUN mkdir -p /app/db /app/static/cache
# Expose port
EXPOSE 5000
# Run with unbuffered output so you can actually see the logs in Portainer
# Run with unbuffered output
ENV PYTHONUNBUFFERED=1
CMD ["python", "app.py"]

View File

@@ -1,76 +0,0 @@
import barcode
from barcode.writer import ImageWriter
from PIL import Image, ImageDraw, ImageFont
import os
import random
# Items to generate
ITEMS = [
{"name": "Plátano", "icon": "🍌"},
{"name": "Manzana", "icon": "🍎"},
{"name": "Tomate", "icon": "🍅"},
{"name": "Lechuga", "icon": "🥬"},
{"name": "Cebolla", "icon": "🧅"},
{"name": "Pan Batido", "icon": "🥖"}
]
os.makedirs('keychain_cards', exist_ok=True)
def generate_card(item):
name = item['name']
# Generate a private EAN-13 starting with 99
# We need 12 digits (the 13th is a checksum added by the library)
random_digits = ''.join([str(random.randint(0, 9)) for _ in range(10)])
code_str = f"99{random_digits}"
# Generate Barcode Image
EAN = barcode.get_barcode_class('ean13')
ean = EAN(code_str, writer=ImageWriter())
# Customizing the output image size
options = {
'module_height': 15.0,
'font_size': 10,
'text_distance': 3.0,
'write_text': True
}
# Create the card canvas (300x450 pixels ~ 2.5x3.5 inches)
card = Image.new('RGB', (300, 400), color='white')
draw = ImageDraw.Draw(card)
# Draw a border for cutting
draw.rectangle([0, 0, 299, 399], outline="black", width=2)
# Try to add the Emoji/Text (Requires a font that supports emojis, otherwise just text)
try:
# If on Linux, try to find a ttf
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 25)
except:
font = ImageFont.load_default()
title_font = ImageFont.load_default()
# Draw Name and Emoji
draw.text((150, 60), item['icon'], fill="black", font=font, anchor="mm")
draw.text((150, 120), name, fill="black", font=title_font, anchor="mm")
# Save barcode to temp file
barcode_img_path = f"keychain_cards/{code_str}_tmp"
ean.save(barcode_img_path, options=options)
# Paste barcode onto card
b_img = Image.open(f"{barcode_img_path}.png")
b_img = b_img.resize((260, 180)) # Resize to fit card
card.paste(b_img, (20, 180))
# Cleanup and save final
os.remove(f"{barcode_img_path}.png")
final_path = f"keychain_cards/{name.replace(' ', '_')}.png"
card.save(final_path)
print(f"Generated {name}: {ean.get_fullcode()}")
for item in ITEMS:
generate_card(item)
print("\nAll cards generated in 'keychain_cards/' folder.")

View File

@@ -1,4 +1,4 @@
# SekiPOS v1.0 🍫🥤
# SekiPOS v3.0 🍫🥤
A reactive POS inventory system for software engineers with a snack addiction. Features real-time UI updates, automatic product discovery via Open Food Facts, and local image caching.
@@ -8,6 +8,45 @@ A reactive POS inventory system for software engineers with a snack addiction. F
- **Local Cache:** Saves images locally to `static/cache` to avoid IP bans.
- **CLP Ready:** Chilean Peso formatting ($1.234) for local commerce.
- **Secure:** Hashed password authentication via Flask-Login.
- **On device scanner:** Add and scan products from within your phone!
## 📦 Building the Desktop App (.exe)
If you want to run SekiPOS as a standalone Windows application with its own embedded browser window, you need to compile it using PyInstaller.
### 1. Prerequisites
You **must** use a stable Python release like **Python 3.11** or **3.12**. Pre-release versions (like 3.14) will fail to compile the PyWebView C# dependencies.
Install the required build tools globally for your stable Python version:
```powershell
py -3.11 -m pip install -r requirements.txt pyinstaller
```
### 2. Prepare `app.py`
Before compiling, scroll to the absolute bottom of `app.py` and ensure the standalone runner is active. It should look like this:
```python
if __name__ == '__main__':
init_db()
# For standalone desktop app with embedded browser, use
run_standalone()
# For docker or traditional server deployment, comment out run_standalone() and uncomment the line below:
# socketio.run(app, host='0.0.0.0', port=5000, debug=True)
```
### 3. Compile
Run this exact command in your terminal. It includes the hidden SocketIO threads and bundles your web templates:
```powershell
py -3.11 -m PyInstaller --noconfirm --onedir --windowed --add-data "templates;templates/" --add-data "static;static/" --hidden-import "engineio.async_drivers.threading" --icon "static/favicon.png" app.py
```
### 4. Post-Build
Your portable app will be generated inside the `dist\app` folder.
* To keep your existing inventory, copy your `db/pos_database.db` and `static/cache/` folders from your source code into the new `dist\app` folder before running the `.exe`.
---
## 🐳 Docker Deployment (Server)
@@ -15,15 +54,16 @@ Build and run the central inventory server:
```bash
# Build the image
docker build -t sekipos .
docker build -t sekipos:latest .
# Run the container (Map port 5000 and persist the database/cache)
docker run -d \
-p 5000:5000 \
-v $(pwd)/pos_database.db:/app/pos_database.db \
-v $(pwd)/static/cache:/app/static/cache \
-v $(pwd)/sekipos/db:/app/db \
-v $(pwd)/sekipos/static/cache:/app/static/cache \
--name sekipos-server \
sekipos
--restart unless-stopped \
sekipos:latest
```
Or use this stack:
@@ -33,11 +73,14 @@ services:
sekipos:
ports:
- 5000:5000
environment:
- TZ=America/Santiago
volumes:
- YOUR_PATH/sekipos/db:/app/db
- YOUR_PATH/sekipos/static/cache:/app/static/cache
container_name: sekipos-server
image: sekipos
image: sekipos:latest
restart: unless-stopped
```
## 🔌 Hardware Scanner Bridge (`ScannerGO`)
@@ -57,6 +100,11 @@ chmod +x ScannerGO-linux
*Note: Ensure the `-url` points to your Docker container's IP address.*
All this program does its send the COM data from the scanner gun to:
```
https://scanner.sekidesu.xyz/scan?content=BAR-CODE
```
## 📦 Local Installation (Development)
If you're too afraid of Docker:
@@ -72,4 +120,12 @@ python app.py
## 📁 Structure
- `app.py`: The inventory/web server.
- `static/cache/`: Local repository for product images.
- `pos_database.db`: SQLite storage.
- `db/pos_database.db`: SQLite storage.
## 📋 TODOs?
- Some form of user registration(?)
- Major refactoring of the codebase
## 🥼 Food Datasets
- https://www.ifpsglobal.com/plu-codes-search
- https://world.openfoodfacts.org

View File

@@ -1,72 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/tarm/serial"
)
func main() {
portName := flag.String("port", "/dev/ttyACM0", "Serial port name")
endpoint := flag.String("url", "https://scanner.sekidesu.xyz/scan", "Target URL endpoint")
baudRate := flag.Int("baud", 115200, "Baud rate")
flag.Parse()
config := &serial.Config{
Name: *portName,
Baud: *baudRate,
ReadTimeout: time.Second * 2,
}
port, err := serial.OpenPort(config)
if err != nil {
fmt.Printf("Error opening port %s: %v\n", *portName, err)
os.Exit(1)
}
defer port.Close()
fmt.Printf("Listening on %s (Baud: %d)...\n", *portName, *baudRate)
fmt.Printf("Sending data to: %s\n", *endpoint)
buf := make([]byte, 128)
for {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
fmt.Printf("Read error: %v\n", err)
}
continue
}
if n > 0 {
content := strings.TrimSpace(string(buf[:n]))
if content != "" {
sendToEndpoint(*endpoint, content)
}
}
}
}
func sendToEndpoint(baseURL, content string) {
client := &http.Client{
Timeout: 5 * time.Second,
}
fullURL := fmt.Sprintf("%s?content=%s", baseURL, url.QueryEscape(content))
resp, err := client.Get(fullURL)
if err != nil {
fmt.Printf("Network Error: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Data: [%s] | Status: %s\n", content, resp.Status)
}

201
app.py
View File

@@ -1,154 +1,79 @@
import os
import sqlite3
import requests
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_from_directory
from flask_socketio import SocketIO, emit
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
import sys
import time
import threading
app = Flask(__name__)
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
socketio = SocketIO(app, cors_allowed_origins="*")
from flask import Flask, redirect, url_for, send_file, jsonify
from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
import webview
from flask_socketio import SocketIO
# Auth Setup
login_manager = LoginManager(app)
login_manager.login_view = 'login'
from core.utils import get_bundled_path, get_persistent_path
from core.db import init_db as init_db_core, get_db_connection
from core.events import socketio
DB_FILE = 'db/pos_database.db'
CACHE_DIR = 'static/cache'
from blueprints.auth import auth_bp, init_login_manager
from blueprints.finance import finance_bp
from blueprints.inventory import inventory_bp
from blueprints.pos import pos_bp
from blueprints.sales import sales_bp
# --- PYINSTALLER WINDOWED MODE FIX ---
if getattr(sys, 'frozen', False) and sys.platform == "win32":
log_file = os.path.join(os.path.dirname(sys.executable), 'seki_crash.log')
sys.stdout = open(log_file, 'w', encoding='utf-8')
sys.stderr = sys.stdout
# --- FLASK INIT ---
app = Flask(
__name__,
template_folder=get_bundled_path('templates'),
static_folder=get_bundled_path('static')
)
app.config['SECRET_KEY'] = 'seki_super_secret_key_99'
# --- DIRECTORY SETUP ---
DB_DIR = get_persistent_path('db')
os.makedirs(DB_DIR, exist_ok=True)
DB_FILE = os.path.join(DB_DIR, "pos_database.db")
app.config['DB_FILE'] = DB_FILE
CACHE_DIR = get_persistent_path(os.path.join('static', 'cache'))
os.makedirs(CACHE_DIR, exist_ok=True)
app.config['CACHE_DIR'] = CACHE_DIR
# --- MODELS ---
class User(UserMixin):
def __init__(self, id, username):
self.id = id
self.username = username
# --- BLUEPRINT REGISTRATION ---
app.register_blueprint(auth_bp)
app.register_blueprint(finance_bp)
app.register_blueprint(inventory_bp)
app.register_blueprint(pos_bp)
app.register_blueprint(sales_bp)
# --- DATABASE LOGIC ---
def init_db():
with sqlite3.connect(DB_FILE) as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS products
(barcode TEXT PRIMARY KEY, name TEXT, price REAL, image_url TEXT)''')
init_login_manager(app)
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
# Default user: admin / Pass: choripan1234
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
if not user:
hashed_pw = generate_password_hash('choripan1234')
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw))
conn.commit()
@login_manager.user_loader
def load_user(user_id):
with sqlite3.connect(DB_FILE) as conn:
user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone()
return User(user[0], user[1]) if user else None
# --- HELPERS ---
def download_image(url, barcode):
if not url or not url.startswith('http'): return url
local_filename = f"{barcode}.jpg"
local_path = os.path.join(CACHE_DIR, local_filename)
if os.path.exists(local_path): return f"/static/cache/{local_filename}"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
r = requests.get(url, headers=headers, stream=True, timeout=5)
if r.status_code == 200:
with open(local_path, 'wb') as f:
for chunk in r.iter_content(1024): f.write(chunk)
return f"/static/cache/{local_filename}"
except: pass
return url
def fetch_from_openfoodfacts(barcode):
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
resp = requests.get(url, headers=headers, timeout=5).json()
if resp.get('status') == 1:
p = resp.get('product', {})
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
local_img = download_image(img_url, barcode)
return {"name": name, "image": local_img}
except: pass
return None
# --- ROUTES ---
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user_in = request.form.get('username')
pass_in = request.form.get('password')
with sqlite3.connect(DB_FILE) as conn:
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
if user and check_password_hash(user[2], pass_in):
login_user(User(user[0], user[1]))
return redirect(url_for('index'))
flash('Invalid credentials.')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user(); return redirect(url_for('login'))
# --- DATABASE INITIALIZATION ---
init_db_core(DB_FILE)
# --- ROOT ROUTE ---
@app.route('/')
@login_required
def index():
with sqlite3.connect(DB_FILE) as conn:
products = conn.execute('SELECT * FROM products').fetchall()
return render_template('index.html', products=products, user=current_user)
return redirect(url_for('inventory.inventory'))
@app.route('/upsert', methods=['POST'])
@login_required
def upsert():
d = request.form
with sqlite3.connect(DB_FILE) as conn:
conn.execute('''INSERT INTO products (barcode, name, price, image_url) VALUES (?,?,?,?)
ON CONFLICT(barcode) DO UPDATE SET name=excluded.name,
price=excluded.price, image_url=excluded.image_url''',
(d['barcode'], d['name'], d['price'], d['image_url']))
conn.commit()
return redirect(url_for('index'))
# --- RUN FUNCTION ---
def start_server():
socketio.run(app, host='127.0.0.1', port=5000, log_output=False, allow_unsafe_werkzeug=True)
@app.route('/delete/<barcode>', methods=['POST'])
@login_required
def delete(barcode):
with sqlite3.connect(DB_FILE) as conn:
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
conn.commit()
# Clean up cache
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
if os.path.exists(img_p): os.remove(img_p)
socketio.emit('product_deleted', {"barcode": barcode})
return redirect(url_for('index'))
@app.route('/scan', methods=['GET'])
def scan():
barcode = request.args.get('content', '').replace('{content}', '')
if not barcode: return jsonify({"err": "empty"}), 400
with sqlite3.connect(DB_FILE) as conn:
p = conn.execute('SELECT * FROM products WHERE barcode = ?', (barcode,)).fetchone()
if p:
socketio.emit('new_scan', {"barcode": p[0], "name": p[1], "price": int(p[2]), "image": p[3]})
return jsonify({"status": "ok"})
else:
ext = fetch_from_openfoodfacts(barcode)
if ext:
socketio.emit('scan_error', {"barcode": barcode, "name": ext['name'], "image": ext['image']})
else:
socketio.emit('scan_error', {"barcode": barcode})
return jsonify({"status": "not_found"}), 404
@app.route('/static/cache/<path:filename>')
def serve_cache(filename):
return send_from_directory(CACHE_DIR, filename)
def run_standalone():
t = threading.Thread(target=start_server)
t.daemon = True
t.start()
time.sleep(2)
webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True)
webview.start(private_mode=False)
if __name__ == '__main__':
init_db()
socketio.run(app, host='0.0.0.0', port=5000, debug=True)
#run_standalone() # Uncomment for desktop app
socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True)

45
app.spec Normal file
View File

@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('templates', 'templates/'), ('static', 'static/')],
hiddenimports=['engineio.async_drivers.threading'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='app',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['static\\favicon.png'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='app',
)

1
blueprints/__init__.py Normal file
View File

@@ -0,0 +1 @@
# blueprints/__init__.py

2
blueprints/__pycache__/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

60
blueprints/auth.py Normal file
View File

@@ -0,0 +1,60 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import check_password_hash, generate_password_hash
from core.db import get_db_connection
auth_bp = Blueprint('auth', __name__)
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
class User(UserMixin):
def __init__(self, id, username):
self.id = id
self.username = username
@login_manager.user_loader
def load_user(user_id):
with get_db_connection() as conn:
user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone()
return User(user[0], user[1]) if user else None
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user_in = request.form.get('username')
pass_in = request.form.get('password')
with get_db_connection() as conn:
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
if user and check_password_hash(user[2], pass_in):
login_user(User(user[0], user[1]))
return redirect(url_for('inventory.inventory'))
flash('Invalid credentials.')
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@auth_bp.route('/settings/update', methods=['POST'])
@login_required
def update_settings():
new_password = request.form.get('password')
profile_pic = request.form.get('profile_pic')
with get_db_connection() as conn:
if new_password and len(new_password) > 0:
hashed_pw = generate_password_hash(new_password)
conn.execute('UPDATE users SET password = ? WHERE id = ?', (hashed_pw, current_user.id))
if profile_pic:
conn.execute('UPDATE users SET profile_pic = ? WHERE id = ?', (profile_pic, current_user.id))
conn.commit()
flash('Configuración actualizada')
return redirect(request.referrer)
def init_login_manager(app):
login_manager.init_app(app)

398
blueprints/finance.py Normal file
View File

@@ -0,0 +1,398 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from core.db import get_db_connection
finance_bp = Blueprint('finance', __name__)
@finance_bp.route('/dicom')
@login_required
def dicom():
with get_db_connection() as conn:
debtors = conn.execute('''SELECT d.id, d.name, d.contact_info,
COALESCE(SUM(t.total - t.amount_paid), 0) as total_balance
FROM debtors d
LEFT JOIN debtor_tickets t ON d.id = t.debtor_id
GROUP BY d.id
ORDER BY total_balance DESC''').fetchall()
return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors)
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>', methods=['GET'])
@login_required
def get_debtor_details(debtor_id):
with get_db_connection() as conn:
# Get tickets with their remaining balance
tickets = conn.execute('''SELECT id, date, total, amount_paid, status,
total - amount_paid as remaining
FROM debtor_tickets
WHERE debtor_id = ?
ORDER BY date DESC''', (debtor_id,)).fetchall()
# Get items for each ticket
result = []
for t in tickets:
items = conn.execute('''SELECT id, barcode, name, price, quantity, subtotal
FROM debtor_ticket_items
WHERE ticket_id = ?''', (t[0],)).fetchall()
result.append({
"id": t[0],
"date": t[1],
"total": t[2],
"amount_paid": t[3],
"status": t[4],
"remaining": t[5],
"items": [{"id": i[0], "barcode": i[1], "name": i[2], "price": i[3], "qty": i[4], "subtotal": i[5]} for i in items]
})
return jsonify(result)
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>/pay', methods=['POST'])
@login_required
def pay_debtor_ticket(debtor_id):
data = request.get_json()
ticket_id = data.get('ticket_id')
amount = float(data.get('amount', 0))
if not ticket_id or amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
with get_db_connection() as conn:
conn.execute('''UPDATE debtor_tickets
SET amount_paid = amount_paid + ?,
status = CASE WHEN (total - amount_paid - ?) <= 0 THEN 'paid' ELSE 'partial' END
WHERE id = ?''', (amount, amount, ticket_id))
# Update status based on final values
conn.execute('''UPDATE debtor_tickets
SET status = CASE
WHEN total - amount_paid <= 0 THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
ELSE 'unpaid'
END
WHERE id = ?''', (ticket_id,))
conn.commit()
return jsonify({"status": "success"})
@finance_bp.route('/api/dicom/pay', methods=['POST'])
@login_required
def dicom_pay():
data = request.get_json()
ticket_id = data.get('ticket_id')
amount = float(data.get('amount', 0))
payment_method = data.get('payment_method', 'efectivo')
if not ticket_id or amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Update the debtor ticket payment
cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', (amount, ticket_id))
# Update status based on final values
cur.execute('''UPDATE debtor_tickets
SET status = CASE
WHEN total - amount_paid <= 0 THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
ELSE 'unpaid'
END
WHERE id = ?''', (ticket_id,))
# Insert into sales table to track daily revenue
cur.execute('''INSERT INTO sales (date, total, payment_method)
VALUES (CURRENT_TIMESTAMP, ?, ?)''', (amount, payment_method))
conn.commit()
return jsonify({"status": "success", "amount": amount}), 200
except Exception as e:
print(f"Dicom Pay Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/update', methods=['POST'])
@login_required
def update_dicom():
data = request.get_json()
name = data.get('name', '').strip()
amount = float(data.get('amount', 0))
notes = data.get('notes', '')
image_url = data.get('image_url', '')
action = data.get('action')
if not name or amount <= 0:
return jsonify({"error": "Nombre y monto válidos son requeridos"}), 400
if action == 'add':
amount = -amount
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('''INSERT INTO dicom (name, amount, notes, image_url, last_updated)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
amount = amount + excluded.amount,
notes = excluded.notes,
image_url = CASE WHEN excluded.image_url != "" THEN excluded.image_url ELSE dicom.image_url END,
last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url))
conn.commit()
return jsonify({"status": "success"}), 200
@finance_bp.route('/api/dicom/<int:debtor_id>', methods=['DELETE'])
@login_required
def delete_dicom(debtor_id):
try:
with get_db_connection() as conn:
conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@finance_bp.route('/gastos')
@login_required
def gastos():
from datetime import datetime
selected_month = request.args.get('month', datetime.now().strftime('%Y-%m'))
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
amount INTEGER NOT NULL
)
''')
sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall()
return render_template('gastos.html',
active_page='gastos',
user=current_user,
sales_total=sales_total,
expenses_total=expenses_total,
net_profit=sales_total - expenses_total,
expenses=expenses_list,
selected_month=selected_month)
@finance_bp.route('/api/gastos', methods=['POST'])
@login_required
def add_gasto():
data = request.get_json()
desc = data.get('description')
amount = data.get('amount')
if not desc or not amount:
return jsonify({"error": "Faltan datos"}), 400
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount)))
conn.commit()
return jsonify({"success": True})
@finance_bp.route('/api/gastos/<int:gasto_id>', methods=['DELETE'])
@login_required
def delete_gasto(gasto_id):
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,))
conn.commit()
return jsonify({"success": True})
@finance_bp.route('/api/dicom/debtors', methods=['GET'])
@login_required
def get_debtors():
try:
with get_db_connection() as conn:
# Check if table exists
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='debtors'")
if not cur.fetchone():
print("Debtors table does not exist!")
return jsonify([])
cur.execute('SELECT id, name, contact_info FROM debtors ORDER BY name')
debtors = cur.fetchall()
print(f"Found {len(debtors)} debtors:", debtors)
return jsonify([{"id": d[0], "name": d[1], "contact_info": d[2]} for d in debtors])
except Exception as e:
print(f"Error getting debtors: {e}")
import traceback
traceback.print_exc()
return jsonify([])
@finance_bp.route('/api/dicom/checkout', methods=['POST'])
@login_required
def dicom_checkout():
try:
data = request.get_json()
cart = data.get('cart', [])
debtor_name = data.get('debtor_name', '').strip()
contact_info = data.get('contact_info', '').strip()
initial_payment = data.get('initial_payment', 0) or 0
if not cart:
return jsonify({"error": "Carrito vacío"}), 400
if not debtor_name:
return jsonify({"error": "Nombre del deudor requerido"}), 400
total = sum(item.get('subtotal', 0) for item in cart)
with get_db_connection() as conn:
cur = conn.cursor()
# Upsert debtor
cur.execute('''INSERT INTO debtors (name, contact_info)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
contact_info = excluded.contact_info''',
(debtor_name, contact_info))
# Get debtor ID
debtor_id = cur.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0]
# Insert debtor ticket
status = 'partial' if initial_payment > 0 else 'unpaid'
cur.execute('''INSERT INTO debtor_tickets (debtor_id, total, amount_paid, status)
VALUES (?, ?, ?, ?)''',
(debtor_id, total, initial_payment, status))
ticket_id = cur.lastrowid
# Insert ticket items and deduct stock
for item in cart:
cur.execute('''INSERT INTO debtor_ticket_items
(ticket_id, barcode, name, price, quantity, subtotal)
VALUES (?, ?, ?, ?, ?, ?)''',
(ticket_id, item.get('barcode', ''), item.get('name'),
item.get('price'), item.get('qty'), item.get('subtotal')))
# Deduct stock (skip for manual products)
if item.get('barcode') and not item.get('barcode', '').startswith(('MANUAL-', 'VARIOS-', 'RAPIDA-')):
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?',
(item.get('qty'), item.get('barcode')))
conn.commit()
return jsonify({"status": "success", "ticket_id": ticket_id, "debtor": debtor_name}), 200
except Exception as e:
print(f"Dicom Checkout Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>', methods=['DELETE'])
@login_required
def delete_debtor(debtor_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Delete items first
cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id IN (SELECT id FROM debtor_tickets WHERE debtor_id = ?)', (debtor_id,))
# Delete tickets
cur.execute('DELETE FROM debtor_tickets WHERE debtor_id = ?', (debtor_id,))
# Delete debtor
cur.execute('DELETE FROM debtors WHERE id = ?', (debtor_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Delete Debtor Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/ticket/<int:ticket_id>', methods=['DELETE'])
@login_required
def delete_ticket(ticket_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Delete items first
cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,))
# Delete ticket
cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Delete Ticket Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/item/<int:item_id>', methods=['DELETE'])
@login_required
def delete_item(item_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Get item info to update ticket total
item = cur.execute('SELECT ticket_id, subtotal FROM debtor_ticket_items WHERE id = ?', (item_id,)).fetchone()
if not item:
return jsonify({"error": "Item no encontrado"}), 404
ticket_id, item_subtotal = item
# Delete item
cur.execute('DELETE FROM debtor_ticket_items WHERE id = ?', (item_id,))
# Check if ticket has remaining items
remaining_items = cur.execute('SELECT COUNT(*) FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,)).fetchone()[0]
if remaining_items == 0:
# Delete ticket if no items left
cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,))
return jsonify({"status": "success", "ticket_deleted": True}), 200
# Update ticket total
cur.execute('UPDATE debtor_tickets SET total = total - ? WHERE id = ?', (item_subtotal, ticket_id))
conn.commit()
return jsonify({"status": "success", "ticket_deleted": False}), 200
except Exception as e:
print(f"Delete Item Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>/pay-all', methods=['POST'])
@login_required
def pay_all_debtor(debtor_id):
try:
data = request.get_json()
amount = float(data.get('amount', 0))
payment_method = data.get('payment_method', 'efectivo')
if amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
with get_db_connection() as conn:
cur = conn.cursor()
# Get all unpaid/partial tickets for this debtor
tickets = cur.execute('''SELECT id, total, amount_paid, total - amount_paid as remaining
FROM debtor_tickets
WHERE debtor_id = ? AND status != 'paid' ''', (debtor_id,)).fetchall()
for ticket in tickets:
ticket_id = ticket[0]
remaining = ticket[3]
if remaining > 0:
# Pay remaining amount
cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?',
(remaining, ticket_id))
# Update status
cur.execute('''UPDATE debtor_tickets SET status = 'paid' WHERE id = ?''', (ticket_id,))
# Record sale
cur.execute('''INSERT INTO sales (date, total, payment_method)
VALUES (CURRENT_TIMESTAMP, ?, ?)''', (remaining, payment_method))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Pay All Debtor Error: {e}")
return jsonify({"error": str(e)}), 500

138
blueprints/inventory.py Normal file
View File

@@ -0,0 +1,138 @@
import os
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from core.db import get_db_connection
from core.utils import download_image
from core.events import socketio
inventory_bp = Blueprint('inventory', __name__)
@inventory_bp.route('/inventory')
@login_required
def inventory():
with get_db_connection() as conn:
products = conn.execute('SELECT * FROM products').fetchall()
return render_template('inventory.html', active_page='inventory', products=products, user=current_user)
@inventory_bp.route("/upsert", methods=["POST"])
@login_required
def upsert():
d = request.form
barcode = d['barcode']
price_str = d.get('price', '0')
stock_str = d.get('stock', '0')
try:
price = float(price_str) if price_str else 0.0
stock = float(stock_str) if stock_str else 0.0
except (ValueError, TypeError):
price = 0.0
stock = 0.0
name = d.get('name', '')
image_url = d.get('image_url', '')
unit_type = d.get('unit_type', 'unit')
cache_dir = current_app.config['CACHE_DIR']
final_image_path = download_image(image_url, barcode, cache_dir)
with get_db_connection() as conn:
conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type)
VALUES (?,?,?,?,?,?)
ON CONFLICT(barcode) DO UPDATE SET
name=excluded.name,
price=excluded.price,
image_url=excluded.image_url,
stock=excluded.stock,
unit_type=excluded.unit_type''',
(barcode, name, price, final_image_path, stock, unit_type))
conn.commit()
return redirect(url_for('inventory.inventory'))
@inventory_bp.route('/upload_image', methods=['POST'])
@login_required
def upload_image():
if 'image' not in request.files:
return jsonify({"error": "No image file provided"}), 400
file = request.files['image']
barcode = request.form.get('barcode', '')
if not barcode:
return jsonify({"error": "No barcode provided"}), 400
if file.filename == '':
return jsonify({"error": "Empty file"}), 400
cache_dir = current_app.config['CACHE_DIR']
ext = '.jpg'
local_filename = f"{secure_filename(barcode)}{ext}"
local_path = os.path.join(cache_dir, local_filename)
file.save(local_path)
image_url = f"/static/cache/{local_filename}"
return jsonify({"image_url": image_url}), 200
@inventory_bp.route('/delete/<barcode>', methods=['POST'])
@login_required
def delete(barcode):
cache_dir = current_app.config['CACHE_DIR']
with get_db_connection() as conn:
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
conn.commit()
img_p = os.path.join(cache_dir, f"{barcode}.jpg")
if os.path.exists(img_p): os.remove(img_p)
if socketio:
socketio.emit('product_deleted', {"barcode": barcode})
return redirect(url_for('inventory.inventory'))
@inventory_bp.route('/bulk_price_update', methods=['POST'])
@login_required
def bulk_price_update():
data = request.get_json()
barcodes = data.get('barcodes', [])
new_price = data.get('new_price')
if not barcodes or new_price is None:
return jsonify({"error": "Missing data"}), 400
try:
with get_db_connection() as conn:
params = [(float(new_price), b) for b in barcodes]
conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params)
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk update failed: {e}")
return jsonify({"error": str(e)}), 500
@inventory_bp.route('/bulk_delete', methods=['POST'])
@login_required
def bulk_delete():
cache_dir = current_app.config['CACHE_DIR']
data = request.get_json()
barcodes = data.get('barcodes', [])
if not barcodes:
return jsonify({"error": "No barcodes provided"}), 400
try:
with get_db_connection() as conn:
conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes)
conn.commit()
for barcode in barcodes:
img_p = os.path.join(cache_dir, f"{barcode}.jpg")
if os.path.exists(img_p):
os.remove(img_p)
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk delete failed: {e}")
return jsonify({"error": str(e)}), 500

115
blueprints/pos.py Normal file
View File

@@ -0,0 +1,115 @@
import os
import time
from flask import Blueprint, render_template, request, jsonify, current_app
from flask_login import login_required, current_user
from core.db import get_db_connection
from core.openfood import fetch_from_openfoodfacts
from core.events import socketio
pos_bp = Blueprint('pos', __name__)
@pos_bp.route('/checkout')
@login_required
def checkout():
with get_db_connection() as conn:
products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall()
return render_template("checkout.html", active_page='checkout', user=current_user, products=products)
@pos_bp.route('/scan', methods=['GET'])
def scan():
barcode = request.args.get('content', '').replace('{content}', '')
if not barcode:
return jsonify({"status": "error", "message": "empty barcode"}), 400
with get_db_connection() as conn:
p = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products WHERE barcode = ?', (barcode,)).fetchone()
if p:
barcode_val, name, price, image_path, stock, unit_type = p
if image_path and image_path.startswith('/static/'):
clean_path = image_path.split('?')[0].lstrip('/')
if not os.path.exists(clean_path):
cache_dir = current_app.config['CACHE_DIR']
ext_data = fetch_from_openfoodfacts(barcode_val, cache_dir)
if ext_data and ext_data.get('image'):
image_path = ext_data['image']
with get_db_connection() as conn:
conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val))
conn.commit()
product_data = {
"barcode": barcode_val,
"name": name,
"price": int(price),
"image": image_path,
"stock": stock,
"unit_type": unit_type
}
socketio.emit('new_scan', product_data)
return jsonify({"status": "ok", "data": product_data}), 200
cache_dir = current_app.config['CACHE_DIR']
ext = fetch_from_openfoodfacts(barcode, cache_dir)
if ext:
external_data = {
"barcode": barcode,
"name": ext['name'],
"image": ext['image'],
"source": "openfoodfacts"
}
socketio.emit('scan_error', external_data)
return jsonify({"status": "not_found", "data": external_data}), 404
socketio.emit('scan_error', {"barcode": barcode})
return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404
@pos_bp.route('/api/checkout', methods=['POST'])
@login_required
def process_checkout():
try:
data = request.get_json()
cart = data.get('cart', [])
payment_method = data.get('payment_method', 'efectivo')
if not cart:
return jsonify({"error": "Cart is empty"}), 400
total = sum(item.get('subtotal', 0) for item in cart)
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('INSERT INTO sales (date, total, payment_method) VALUES (CURRENT_TIMESTAMP, ?, ?)', (total, payment_method))
sale_id = cur.lastrowid
for item in cart:
cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal)
VALUES (?, ?, ?, ?, ?, ?)''',
(sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal']))
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode']))
conn.commit()
return jsonify({"status": "success", "sale_id": sale_id}), 200
except Exception as e:
print(f"Checkout Error: {e}")
return jsonify({"error": str(e)}), 500
@pos_bp.route('/api/scale/weight', methods=['POST'])
def update_scale_weight():
data = request.get_json()
weight_grams = data.get('weight', 0)
weight_kg = round(weight_grams / 1000, 3)
socketio.emit('scale_update', {
"grams": weight_grams,
"kilograms": weight_kg,
"timestamp": time.time()
})
return jsonify({"status": "received"}), 200

147
blueprints/sales.py Normal file
View File

@@ -0,0 +1,147 @@
from flask import Blueprint, render_template, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from core.db import get_db_connection
import io
import zipfile
from datetime import datetime
import os
sales_bp = Blueprint('sales', __name__)
@sales_bp.route('/export/db')
@login_required
def export_db():
db_file = current_app.config['DB_FILE']
if os.path.exists(db_file):
return send_file(db_file, as_attachment=True, download_name=f"SekiPOS_Backup_{datetime.now().strftime('%Y%m%d')}.db", mimetype='application/x-sqlite3')
return "Error: Database file not found", 404
@sales_bp.route('/export/images')
@login_required
def export_images():
cache_dir = current_app.config['CACHE_DIR']
if not os.path.exists(cache_dir) or not os.listdir(cache_dir):
return "No images found to export", 404
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(cache_dir):
for file in files:
file_path = os.path.join(root, file)
zf.write(file_path, arcname=file)
memory_file.seek(0)
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip"
)
@sales_bp.route('/sales')
@login_required
def sales():
selected_date = request.args.get('date')
payment_method = request.args.get('payment_method')
page = request.args.get('page', 1, type=int)
per_page = 100
with get_db_connection() as conn:
cur = conn.cursor()
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?"
week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')"
month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')"
daily_params = [target_date]
week_params = []
month_params = []
if payment_method:
daily_query += " AND payment_method = ?"
week_query += " AND payment_method = ?"
month_query += " AND payment_method = ?"
daily_params.append(payment_method)
week_params.append(payment_method)
month_params.append(payment_method)
stats = {
"daily": cur.execute(daily_query, tuple(daily_params)).fetchone()[0] or 0,
"week": cur.execute(week_query, tuple(week_params)).fetchone()[0] or 0,
"month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0
}
base_query = "FROM sales WHERE 1=1"
params = []
if selected_date:
base_query += " AND date(date, 'localtime') = ?"
params.append(selected_date)
if payment_method:
base_query += " AND payment_method = ?"
params.append(payment_method)
stats_query = f"SELECT COUNT(*), SUM(total) {base_query}"
count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone()
total_count = count_res or 0
total_sum = sum_res or 0
total_pages = (total_count + per_page - 1) // per_page
filtered_stats = {
"total": total_sum,
"count": total_count
}
offset = (page - 1) * per_page
data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?"
sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall()
return render_template('sales.html',
active_page='sales',
user=current_user,
sales=sales_data,
stats=stats,
filtered_stats=filtered_stats,
selected_date=selected_date,
selected_payment=payment_method,
current_page=page,
total_pages=total_pages)
@sales_bp.route('/api/sale/<int:sale_id>')
@login_required
def get_sale_details(sale_id):
with get_db_connection() as conn:
items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items]
return jsonify(item_list), 200
@sales_bp.route('/api/sale/<int:sale_id>', methods=['DELETE'])
@login_required
def reverse_sale(sale_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
for barcode, qty in items:
cur.execute('UPDATE products SET stock = stock + ? WHERE barcode = ?', (qty, barcode))
cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,))
cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Reverse Sale Error: {e}")
return jsonify({"error": str(e)}), 500

2
build/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
core/__pycache__/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

79
core/db.py Normal file
View File

@@ -0,0 +1,79 @@
import sqlite3
_db_file = None
def init_db(db_file):
global _db_file
_db_file = db_file
with get_db_connection() as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS products
(barcode TEXT PRIMARY KEY,
name TEXT,
price REAL,
image_url TEXT,
stock REAL DEFAULT 0,
unit_type TEXT DEFAULT 'unit')''')
conn.execute('''CREATE TABLE IF NOT EXISTS sales
(id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
total REAL,
payment_method TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS sale_items
(id INTEGER PRIMARY KEY AUTOINCREMENT,
sale_id INTEGER,
barcode TEXT,
name TEXT,
price REAL,
quantity REAL,
subtotal REAL,
FOREIGN KEY(sale_id) REFERENCES sales(id))''')
conn.execute('''CREATE TABLE IF NOT EXISTS dicom
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
amount REAL DEFAULT 0,
notes TEXT,
image_url TEXT,
last_updated TEXT DEFAULT CURRENT_TIMESTAMP)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtors
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
contact_info TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtor_tickets
(id INTEGER PRIMARY KEY AUTOINCREMENT,
debtor_id INTEGER NOT NULL,
date TEXT DEFAULT CURRENT_TIMESTAMP,
total REAL NOT NULL,
amount_paid REAL DEFAULT 0,
status TEXT DEFAULT 'unpaid',
FOREIGN KEY(debtor_id) REFERENCES debtors(id) ON DELETE CASCADE)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtor_ticket_items
(id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
barcode TEXT,
name TEXT,
price REAL,
quantity REAL,
subtotal REAL,
FOREIGN KEY(ticket_id) REFERENCES debtor_tickets(id) ON DELETE CASCADE)''')
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
if not user:
from werkzeug.security import generate_password_hash
hashed_pw = generate_password_hash('choripan1234')
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw))
conn.commit()
def get_db_connection():
if _db_file is None:
raise RuntimeError("Database not initialized. Call init_db(db_file) first.")
return sqlite3.connect(_db_file)

3
core/events.py Normal file
View File

@@ -0,0 +1,3 @@
from flask_socketio import SocketIO
socketio = SocketIO()

23
core/openfood.py Normal file
View File

@@ -0,0 +1,23 @@
import requests
from core.utils import download_image
def fetch_from_openfoodfacts(barcode, cache_dir):
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
resp = requests.get(url, headers=headers, timeout=5).json()
if resp.get('status') == 1:
p = resp.get('product', {})
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
if img_url:
local_img = download_image(img_url, barcode, cache_dir)
return {"name": name, "image": local_img}
return {"name": name, "image": None}
except Exception as e:
print(f"API Error: {e}")
return None

46
core/utils.py Normal file
View File

@@ -0,0 +1,46 @@
import os
import sys
import mimetypes
import requests
def get_bundled_path(relative_path):
"""Path for read-only files packed inside the .exe (templates, static)"""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_path = project_root
return os.path.join(base_path, relative_path)
def get_persistent_path(relative_path):
"""Path for read/write files that must survive reboots (db, cache)"""
if getattr(sys, 'frozen', False):
base_path = os.path.dirname(sys.executable)
else:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_path = project_root
return os.path.join(base_path, relative_path)
def download_image(url, barcode, cache_dir):
if not url or not url.startswith('http'):
return url
try:
headers = {'User-Agent': 'SekiPOS/1.2'}
with requests.get(url, headers=headers, stream=True, timeout=5) as r:
r.raise_for_status()
content_type = r.headers.get('content-type')
ext = mimetypes.guess_extension(content_type) or '.jpg'
local_filename = f"{barcode}{ext}"
local_path = os.path.join(cache_dir, local_filename)
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return f"/static/cache/{local_filename}"
except Exception as e:
print(f"Download failed: {e}")
return url

2
dist/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
extensions/go/DataToolsGO/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.json
imageTools-*

View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Define binary names
LINUX_BIN="imageTools-linux"
LINUX_ARM_BIN="imageTools-linuxARMv7"
WINDOWS_BIN="imageTools-windows.exe"
echo "Starting build process..."
# Build for Linux (64-bit)
echo "Building for Linux..."
GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_BIN"
else
echo "Failed to build Linux binary"
exit 1
fi
# Build for Windows (64-bit)
echo "Building for Windows..."
GOOS=windows GOARCH=amd64 go build -o "$WINDOWS_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $WINDOWS_BIN"
else
echo "Failed to build Windows binary"
exit 1
fi
# Build for Linux ARM (ARMv7)
echo "Building for Linux ARMv7..."
GOOS=linux GOARCH=arm GOARM=7 go build -o "$LINUX_ARM_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_ARM_BIN"
else
echo "Failed to build Linux ARMv7 binary"
exit 1
fi
echo "Build complete."

View File

@@ -0,0 +1,5 @@
module dataTools
go 1.25.7
require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646

View File

@@ -0,0 +1,2 @@
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=

View File

@@ -0,0 +1,84 @@
package main
import (
"flag"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"strings"
"github.com/nfnt/resize"
)
func main() {
// Command line arguments
dirPath := flag.String("dir", "./", "Directory containing images")
maxWidth := flag.Uint("width", 1000, "Maximum width for resizing")
quality := flag.Int("quality", 75, "JPEG quality (1-100)")
flag.Parse()
files, err := os.ReadDir(*dirPath)
if err != nil {
fmt.Printf("Error reading directory: %v\n", err)
return
}
fmt.Printf("Processing images in %s (Max Width: %d, Quality: %d)\n", *dirPath, *maxWidth, *quality)
for _, file := range files {
if file.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(file.Name()))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
continue
}
filePath := filepath.Join(*dirPath, file.Name())
processImage(filePath, *maxWidth, *quality)
}
fmt.Println("Done. Your storage can finally breathe again.")
}
func processImage(path string, maxWidth uint, quality int) {
file, err := os.Open(path)
if err != nil {
fmt.Printf("Failed to open %s: %v\n", path, err)
return
}
img, _, err := image.Decode(file)
file.Close()
if err != nil {
fmt.Printf("Failed to decode %s: %v\n", path, err)
return
}
// Only resize if original is wider than maxWidth
bounds := img.Bounds()
var finalImg image.Image
if uint(bounds.Dx()) > maxWidth {
finalImg = resize.Resize(maxWidth, 0, img, resize.Lanczos3)
fmt.Printf("Resized and compressed: %s\n", filepath.Base(path))
} else {
finalImg = img
fmt.Printf("Compressed (no resize needed): %s\n", filepath.Base(path))
}
// Overwrite the original file
out, err := os.Create(path)
if err != nil {
fmt.Printf("Failed to create output file %s: %v\n", path, err)
return
}
defer out.Close()
err = jpeg.Encode(out, finalImg, &jpeg.Options{Quality: quality})
if err != nil {
fmt.Printf("Failed to encode %s: %v\n", path, err)
}
}

View File

@@ -0,0 +1,8 @@
module MPPOS
go 1.25.7
require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
)

View File

@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@@ -0,0 +1,143 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
)
// Request structures based on MP documentation
type PrintRequest struct {
Type string `json:"type"`
ExternalReference string `json:"external_reference"`
Config PrintConfig `json:"config"`
Content string `json:"content"`
}
type PrintConfig struct {
Point PointSettings `json:"point"`
}
type PointSettings struct {
TerminalID string `json:"terminal_id"`
Subtype string `json:"subtype"`
}
func main() {
err := godotenv.Load("../.env")
if err != nil {
fmt.Println("Error loading .env file")
}
// Example receipt using supported tags: {b}, {center}, {br}, {s}
receiptContent := "{center}{b}SEKIPOS VENTA{/b}{br}" +
"--------------------------------{br}" +
"{left}Producto: Choripan Premium{br}" +
"{left}Total: $5.500{br}" +
"--------------------------------{br}" +
"{center}{s}Gracias por su compra{/s}{br}"
resp, err := SendPrintAction(receiptContent)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Response: %s\n", resp)
}
func SendPrintAction(content string) (string, error) {
apiURL := "https://api.mercadopago.com/terminals/v1/actions"
accessToken := os.Getenv("MP_ACCESS_TOKEN")
terminalID := os.Getenv("MP_TERMINAL_ID")
payload := PrintRequest{
Type: "print", // Required
ExternalReference: fmt.Sprintf("ref_%d", time.Now().Unix()),
Config: PrintConfig{
Point: PointSettings{
TerminalID: terminalID,
Subtype: "custom", // For text with tags
},
},
Content: content, // Must be 100-4096 chars
}
jsonData, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
// Mandatory Unique UUID V4
req.Header.Set("X-Idempotency-Key", uuid.New().String())
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MP Error %d: %s", resp.StatusCode, string(body))
}
return string(body), nil
}
func PartialRefund(orderID string, paymentID string, amount string) (string, error) {
// Endpoint para reembolsos según la referencia de API
apiURL := fmt.Sprintf("https://api.mercadopago.com/v1/orders/%s/refund", orderID)
token := os.Getenv("MP_ACCESS_TOKEN")
payload := map[string]interface{}{
"transactions": []map[string]string{
{
"id": paymentID,
"amount": amount, // Debe ser un string sin decimales
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Idempotency-Key", uuid.New().String())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
resBody, _ := io.ReadAll(resp.Body)
return string(resBody), nil
}
func GetOrderStatus(orderID string) (string, error) {
apiURL := fmt.Sprintf("https://api.mercadopago.com/v1/orders/%s", orderID)
token := os.Getenv("MP_ACCESS_TOKEN")
req, _ := http.NewRequest("GET", apiURL, nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
resBody, _ := io.ReadAll(resp.Body)
return string(resBody), nil
}

2
extensions/go/ScannerCOM/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.json
COMScannerGO-*

View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Define binary names
LINUX_BIN="COMScannerGO-linux"
LINUX_ARM_BIN="COMScannerGO-linuxARMv7"
WINDOWS_BIN="COMScannerGO-windows.exe"
echo "Starting build process..."
# Build for Linux (64-bit)
echo "Building for Linux..."
GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_BIN"
else
echo "Failed to build Linux binary"
exit 1
fi
# Build for Windows (64-bit)
echo "Building for Windows..."
GOOS=windows GOARCH=amd64 go build -o "$WINDOWS_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $WINDOWS_BIN"
else
echo "Failed to build Windows binary"
exit 1
fi
# Build for Linux ARM (ARMv7)
echo "Building for Linux ARMv7..."
GOOS=linux GOARCH=arm GOARM=7 go build -o "$LINUX_ARM_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_ARM_BIN"
else
echo "Failed to build Linux ARMv7 binary"
exit 1
fi
echo "Build complete."

View File

@@ -1,6 +1,6 @@
module ScannerGO
go 1.25.7
go 1.24.0
require github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07

View File

@@ -0,0 +1,175 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/tarm/serial"
)
type Config struct {
Port string `json:"port"`
URL string `json:"url"`
BaudRate int `json:"baud"`
Delimiter string `json:"delimiter"`
FlowControl string `json:"flow_control"`
}
var defaultConfig = Config{
Port: "/dev/ttyACM0",
URL: "https://scanner.sekidesu.xyz/scan",
BaudRate: 115200,
Delimiter: "\n",
FlowControl: "none",
}
const configPath = "config.json"
func main() {
cfg := loadConfig()
portName := flag.String("port", cfg.Port, "Serial port name")
endpoint := flag.String("url", cfg.URL, "Target URL endpoint")
baudRate := flag.Int("baud", cfg.BaudRate, "Baud rate")
delim := flag.String("delim", cfg.Delimiter, "Line delimiter")
flow := flag.String("flow", cfg.FlowControl, "Flow control: none, hardware, software")
save := flag.Bool("save", false, "Save current parameters to config.json")
flag.Parse()
cfg.Port = *portName
cfg.URL = *endpoint
cfg.BaudRate = *baudRate
cfg.Delimiter = *delim
cfg.FlowControl = *flow
if *save {
saveConfig(cfg)
fmt.Println("Settings saved to", configPath)
}
serialConfig := &serial.Config{
Name: cfg.Port,
Baud: cfg.BaudRate,
ReadTimeout: time.Millisecond * 500,
}
// tarm/serial uses boolean flags for flow control if available in the version used
// If your version doesn't support these fields, you may need to update the package
// or manage the lines manually via the file descriptor.
/* Note: tarm/serial usually requires specific fork or version
for full RTS/CTS hardware flow control support.
*/
port, err := serial.OpenPort(serialConfig)
if err != nil {
fmt.Printf("Error opening port %s: %v\n", cfg.Port, err)
os.Exit(1)
}
defer port.Close()
fmt.Printf("Listening on %s (Baud: %d, Flow: %s)...\n", cfg.Port, cfg.BaudRate, cfg.FlowControl)
scanner := bufio.NewScanner(port)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, []byte(cfg.Delimiter)); i >= 0 {
return i + len(cfg.Delimiter), data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
for scanner.Scan() {
rawContent := scanner.Text()
content := strings.TrimFunc(rawContent, func(r rune) bool {
return r < 32 || r > 126
})
if content != "" {
sendToEndpoint(cfg.URL, content)
}
}
if err := scanner.Err(); err != nil {
fmt.Printf("Scanner error: %v\n", err)
}
}
func loadConfig() Config {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
saveConfig(defaultConfig)
return defaultConfig
}
file, err := os.Open(configPath)
if err != nil {
return defaultConfig
}
defer file.Close()
var cfg Config
decoder := json.NewDecoder(file)
if err := decoder.Decode(&cfg); err != nil {
return defaultConfig
}
if cfg.Delimiter == "" {
cfg.Delimiter = "\n"
}
if cfg.FlowControl == "" {
cfg.FlowControl = "none"
}
return cfg
}
func saveConfig(cfg Config) {
file, err := os.Create(configPath)
if err != nil {
fmt.Printf("Failed to create/save config: %v\n", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
encoder.Encode(cfg)
}
func sendToEndpoint(baseURL, content string) {
client := &http.Client{
Timeout: 5 * time.Second,
}
fullURL := fmt.Sprintf("%s?content=%s", baseURL, url.QueryEscape(content))
resp, err := client.Get(fullURL)
if err != nil {
fmt.Printf("Network Error: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
}
fmt.Printf("Data: [%s] | Status: %s\n", content, resp.Status)
if len(body) > 0 {
fmt.Printf("Response: %s\n", string(body))
}
fmt.Println(strings.Repeat("-", 30))
}

2
extensions/go/ScannerHID/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.json
HIDScannerGO-*

View File

@@ -0,0 +1,18 @@
#!/bin/bash
LINUX_BIN="HIDScannerGO-linux"
WINDOWS_BIN="HIDScannerGO-windows.exe"
echo "Starting build process..."
# 1. Build for Linux (Host)
echo "Building for Linux (64-bit)..."
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" .
# 2. Build for Windows (Needs MinGW)
echo "Building for Windows (64-bit)..."
# We must point to the mingw gcc and enable CGO
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
go build -ldflags="-H=windowsgui" -o "$WINDOWS_BIN" .
echo "Build complete."

View File

@@ -0,0 +1,46 @@
module ScannerGO
go 1.24.0
require (
fyne.io/fyne/v2 v2.7.3
gioui.org v0.9.0
github.com/google/gousb v1.1.3
)
require (
fyne.io/systray v1.12.0 // indirect
gioui.org/shader v1.0.8 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.3.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,93 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/gousb v1.1.3 h1:xt6M5TDsGSZ+rlomz5Si5Hmd/Fvbmo2YCJHN+yGaK4o=
github.com/google/gousb v1.1.3/go.mod h1:GGWUkK0gAXDzxhwrzetW592aOmkkqSGcj5KLEgmCVUg=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,281 @@
package main
import (
_ "embed"
"encoding/json"
"flag"
"fmt"
"image/color"
"net/http"
"os"
"strconv"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/google/gousb"
)
//go:embed icon.ico
var iconData []byte
type HexUint16 uint16
func (h HexUint16) MarshalJSON() ([]byte, error) {
return json.Marshal(fmt.Sprintf("0x%04X", h))
}
func (h *HexUint16) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
s = strings.TrimPrefix(s, "0x")
val, err := strconv.ParseUint(s, 16, 16)
if err != nil {
return err
}
*h = HexUint16(val)
return nil
}
type Config struct {
TargetURL string `json:"target_url"`
VendorID HexUint16 `json:"vendor_id"`
ProductID HexUint16 `json:"product_id"`
FallbackVID HexUint16 `json:"fallback_vendor_id"`
FallbackPID HexUint16 `json:"fallback_product_id"`
}
var hidMap = map[byte]string{
4: "a", 5: "b", 6: "c", 7: "d", 8: "e", 9: "f", 10: "g", 11: "h", 12: "i",
13: "j", 14: "k", 15: "l", 16: "m", 17: "n", 18: "o", 19: "p", 20: "q",
21: "r", 22: "s", 23: "t", 24: "u", 25: "v", 26: "w", 27: "x", 28: "y", 29: "z",
30: "1", 31: "2", 32: "3", 33: "4", 34: "5", 35: "6", 36: "7", 37: "8", 38: "9", 39: "0",
44: " ", 45: "-", 46: "=", 55: ".", 56: "/",
}
type BridgeApp struct {
urlEntry *widget.Entry
status *canvas.Text
logList *widget.List
logs []string
window fyne.Window
config Config
isCLI bool
}
func (b *BridgeApp) saveConfig() {
b.config.TargetURL = b.urlEntry.Text
data, _ := json.MarshalIndent(b.config, "", " ")
_ = os.WriteFile("config.json", data, 0644)
}
func loadConfig() Config {
conf := Config{
TargetURL: "https://scanner.sekidesu.xyz/scan",
VendorID: 0xFFFF,
ProductID: 0x0035,
FallbackVID: 0x04B3,
FallbackPID: 0x3107,
}
file, err := os.ReadFile("config.json")
if err == nil {
json.Unmarshal(file, &conf)
} else {
data, _ := json.MarshalIndent(conf, "", " ")
os.WriteFile("config.json", data, 0644)
}
return conf
}
func main() {
cliMode := flag.Bool("cli", false, "Run in CLI mode without GUI")
flag.Parse()
conf := loadConfig()
bridge := &BridgeApp{
config: conf,
isCLI: *cliMode,
}
if *cliMode {
fmt.Println("Running in CLI mode...")
bridge.usbListenLoop()
return
}
a := app.New()
w := a.NewWindow("POS Hardware Bridge (Go)")
w.SetIcon(fyne.NewStaticResource("icon.ico", iconData))
bridge.window = w
bridge.urlEntry = widget.NewEntry()
bridge.urlEntry.SetText(conf.TargetURL)
bridge.status = canvas.NewText("Status: Booting...", color.Black)
bridge.status.TextSize = 14
bridge.logList = widget.NewList(
func() int { return len(bridge.logs) },
func() fyne.CanvasObject { return widget.NewLabel("template") },
func(i widget.ListItemID, o fyne.CanvasObject) {
o.(*widget.Label).SetText(bridge.logs[i])
},
)
content := container.NewBorder(
container.NewVBox(
widget.NewLabel("Target POS Endpoint:"),
bridge.urlEntry,
bridge.status,
widget.NewLabel("Activity Log:"),
),
nil, nil, nil,
bridge.logList,
)
w.SetContent(content)
w.Resize(fyne.NewSize(500, 400))
w.SetOnClosed(func() {
if !bridge.isCLI {
bridge.config.TargetURL = bridge.urlEntry.Text
data, err := json.MarshalIndent(bridge.config, "", " ")
if err == nil {
_ = os.WriteFile("config.json", data, 0644)
fmt.Println("Configuration saved.")
}
}
})
go bridge.usbListenLoop()
w.ShowAndRun()
}
func (b *BridgeApp) addLog(msg string) {
ts := time.Now().Format("15:04:05")
formatted := fmt.Sprintf("[%s] %s", ts, msg)
if b.isCLI {
fmt.Println(formatted)
return
}
fyne.DoAndWait(func() {
b.logs = append([]string{formatted}, b.logs...)
if len(b.logs) > 15 {
b.logs = b.logs[:15]
}
b.logList.Refresh()
})
}
func (b *BridgeApp) updateStatus(msg string, col color.Color) {
if b.isCLI {
fmt.Printf("STATUS: %s\n", msg)
return
}
fyne.DoAndWait(func() {
b.status.Text = msg
b.status.Color = col
b.status.Refresh()
})
}
func (b *BridgeApp) sendToPos(barcode string) {
url := b.config.TargetURL
if !b.isCLI {
url = b.urlEntry.Text
}
b.addLog(fmt.Sprintf("Captured: %s. Sending to %s", barcode, url))
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url + "?content=" + barcode)
if err != nil {
b.addLog("HTTP Error: Backend unreachable")
return
}
defer resp.Body.Close()
b.addLog(fmt.Sprintf("Success: POS returned %d", resp.StatusCode))
}
func (b *BridgeApp) usbListenLoop() {
ctx := gousb.NewContext()
defer ctx.Close()
for {
dev, err := ctx.OpenDeviceWithVIDPID(gousb.ID(b.config.VendorID), gousb.ID(b.config.ProductID))
if (err != nil || dev == nil) && (b.config.FallbackVID != 0) {
dev, err = ctx.OpenDeviceWithVIDPID(gousb.ID(b.config.FallbackVID), gousb.ID(b.config.FallbackPID))
}
if err != nil || dev == nil {
b.updateStatus("Scanner unplugged. Waiting...", color.NRGBA{200, 0, 0, 255})
time.Sleep(2 * time.Second)
continue
}
b.updateStatus(fmt.Sprintf("Scanner Ready (0x%04X)", dev.Desc.Vendor), color.NRGBA{0, 180, 0, 255})
intf, done, err := dev.DefaultInterface()
if err != nil {
b.addLog("Error claiming interface")
dev.Close()
time.Sleep(2 * time.Second)
continue
}
var inEp *gousb.InEndpoint
for _, epDesc := range intf.Setting.Endpoints {
if epDesc.Direction == gousb.EndpointDirectionIn {
inEp, _ = intf.InEndpoint(epDesc.Number)
break
}
}
if inEp == nil {
b.addLog("No IN endpoint found")
done()
dev.Close()
continue
}
currentBarcode := ""
buf := make([]byte, inEp.Desc.MaxPacketSize)
for {
n, err := inEp.Read(buf)
if err != nil {
break
}
if n < 3 || buf[2] == 0 {
continue
}
modifier := buf[0]
keycode := buf[2]
isShift := (modifier == 2 || modifier == 32)
if keycode == 40 {
if currentBarcode != "" {
go b.sendToPos(currentBarcode)
currentBarcode = ""
}
} else if val, ok := hidMap[keycode]; ok {
if isShift && len(val) == 1 && val[0] >= 'a' && val[0] <= 'z' {
val = string(val[0] - 32)
}
currentBarcode += val
}
}
done()
dev.Close()
b.addLog("Hardware connection lost. Reconnecting...")
}
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View File

@@ -0,0 +1,18 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:waveshare_rp2040_zero]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = waveshare_rp2040_zero
framework = arduino
monitor_speed = 115200
lib_deps =
https://github.com/Chris--A/Keypad
bogde/HX711@^0.7.5

View File

@@ -0,0 +1,84 @@
#include <Arduino.h>
#include "HX711.h"
const int LOADCELL_DOUT_PIN = 2;
const int LOADCELL_SCK_PIN = 3;
HX711 scale;
float smoothedWeight = 0.0;
float alpha = 0.3;
float calibration_factor = 111.17;
float known_weight = 796.0;
// Stability Lock Variables
float lastDisplayedWeight = 0.0;
const float STABILITY_THRESHOLD = 0.5;
void calibrateScale() {
Serial.println("--- Calibration Mode ---");
Serial.println("1. Clear scale, type 't' to tare.");
Serial.print("2. Place "); Serial.print(known_weight); Serial.println("g weight.");
Serial.println("3. Type 'c' to confirm weight.");
while (true) {
if (Serial.available()) {
char c = Serial.read();
if (c == 't') {
scale.tare();
Serial.println("Tared! Place weight and type 'c'...");
} else if (c == 'c') {
long reading = scale.get_value(15);
calibration_factor = (float)reading / known_weight;
scale.set_scale(calibration_factor);
Serial.print("New Factor: "); Serial.println(calibration_factor);
Serial.println("Calibration Done! Exiting mode.");
break;
}
}
}
}
void setup() {
Serial.begin(115200);
scale.begin(LOADCELL_DOUT_PIN, LOADCELL_SCK_PIN);
scale.set_gain(128);
scale.set_scale(calibration_factor);
scale.tare();
Serial.println("HX711 Ready (Stable Lock Mode)");
}
void loop() {
if (scale.is_ready()) {
float currentReading = scale.get_units(1);
smoothedWeight = (alpha * currentReading) + (1.0 - alpha) * smoothedWeight;
// 1. Calculate the intended new display value
float targetWeight = round(smoothedWeight * 2.0) / 2.0;
if (abs(targetWeight) < 1.0) targetWeight = 0.0;
// 2. Stability Check
// Only update lastDisplayedWeight if the change exceeds the threshold
if (abs(targetWeight - lastDisplayedWeight) >= STABILITY_THRESHOLD) {
lastDisplayedWeight = targetWeight;
}
Serial.print("Weight: ");
Serial.print(lastDisplayedWeight, 1);
Serial.println(" g");
}
if (Serial.available()) {
char cmd = Serial.read();
if (cmd == 't') {
scale.tare();
smoothedWeight = 0;
lastDisplayedWeight = 0; // Force display to zero immediately
Serial.println(">> Tared");
} else if (cmd == 'k') {
calibrateScale();
}
}
delay(10);
}

View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View File

@@ -0,0 +1,16 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:waveshare_rp2040_zero]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = waveshare_rp2040_zero
framework = arduino
monitor_speed = 115200
lib_deps = https://github.com/Chris--A/Keypad

View File

@@ -0,0 +1,50 @@
#ifndef TM1621_CONFIG_H
#define TM1621_CONFIG_H
#include <Arduino.h>
const uint8_t digitMap[] = {
0b11111100, // 0
0b00000110, // 1
0b01011011, // 2
0b01001111, // 3
0b01100110, // 4
0b01101101, // 5
0b01111101, // 6
0b00000111, // 7
0b01111111, // 8
0b01101111 // 9
};
const int arrows[] = {283,363,183,203}; //tare,?,?,Zero
const int battery[] = {63,83,103}; //LOW / MED / FULL
// Following your pattern: {A, B, C, D, E, F, G}
const int row1_d1[] = {300, 301, 302, 313, 312, 310, 311}; // Addresses 30 & 31
const int row1_d2[] = {280, 281, 282, 293, 292, 290, 291}; // Addresses 28 & 29
const int row1_d3[] = {260, 261, 262, 273, 272, 270, 271}; // Addresses 26 & 27
const int row1_d4[] = {240, 241, 242, 253, 252, 250, 251}; // Addresses 24 & 25
const int row1_d5[] = {220, 221, 222, 233, 232, 230, 231}; // Addresses 22 & 23
const int row1_decimal[] = {263,232,223}; //XX.X.X.X
const int row2_d1[] = {200, 201, 202, 213, 212, 210, 211}; // Addresses 20 & 21
const int row2_d2[] = {180, 181, 182, 193, 192, 190, 191}; // Addresses 18 & 19
const int row2_d3[] = {160, 161, 162, 173, 172, 170, 171}; // Addresses 16 & 17
const int row2_d4[] = {140, 141, 142, 153, 152, 150, 151}; // Addresses 14 & 15
const int row2_d5[] = {120, 121, 122, 133, 132, 130, 131}; // Addresses 12 & 13
const int row2_decimal[] = {163,143,123}; //XX.X.X.X
const int row3_d1[] = {100, 101, 102, 113, 112, 110, 111}; // Addresses 10 & 11
const int row3_d2[] = {80, 81, 82, 93, 92, 90, 91}; // Addresses 8 & 9
const int row3_d3[] = {60, 61, 62, 73, 72, 70, 71}; // Addresses 6 & 7
const int row3_d4[] = {40, 41, 42, 53, 52, 50, 51}; // Addresses 4 & 5
const int row3_d5[] = {20, 21, 22, 33, 32, 30, 31}; // Addresses 2 & 3
const int row3_d6[] = {0, 1, 2, 13, 12, 10, 11}; // Addresses 0 & 1
const int row3_decimal[] = {43,23,3}; //XX.X.X.X
const int* digitsRow1[] = {row1_d1, row1_d2, row1_d3, row1_d4, row1_d5};
const int* digitsRow2[] = {row2_d1, row2_d2, row2_d3, row2_d4, row2_d5};
const int* digitsRow3[] = {row3_d1, row3_d2, row3_d3, row3_d4, row3_d5, row3_d6};
const int* decimals[] = {row1_decimal, row2_decimal, row3_decimal};
#endif

View File

@@ -0,0 +1,270 @@
#include <Arduino.h>
#include "TM1621_Config.h"
#include <Keypad.h>
#define SCLK_SDI0819 1
#define DOUT_SDI0819 2
#define LCD_DATA 3
#define LCD_WR 4
#define LCD_CS 5
#define BUZZER_PIN 6
#define BACKLIGHT_PIN 7
int currentAddr = 0;
int currentBit = 0;
void writeBits(uint32_t data, uint8_t count) {
for (int8_t i = count - 1; i >= 0; i--) {
digitalWrite(LCD_WR, LOW);
digitalWrite(LCD_DATA, (data >> i) & 0x01);
digitalWrite(LCD_WR, HIGH);
}
}
void sendCmd(uint8_t cmd) {
digitalWrite(LCD_CS, LOW);
writeBits(0x04, 3); // Binary 100 [cite: 432]
writeBits(cmd, 8); // Command [cite: 413, 534]
writeBits(0, 1); // X bit [cite: 416]
digitalWrite(LCD_CS, HIGH);
}
void writeAddr(uint8_t addr, uint8_t data) {
digitalWrite(LCD_CS, LOW);
uint16_t header = (0x05 << 6) | (addr & 0x3F); // Mode 101 [cite: 475]
writeBits(header, 9);
writeBits(data & 0x0F, 4); // 4 bits of data [cite: 475]
digitalWrite(LCD_CS, HIGH);
}
void updateDisplay() {
// Clear all segments first
for (int i = 0; i < 32; i++) {
writeAddr(i, 0x00);
}
// Light up only the current bit
writeAddr(currentAddr, (1 << currentBit));
Serial.print(">>> CURRENT - Address: ");
Serial.print(currentAddr);
Serial.print(" | Bit (COM): ");
Serial.println(currentBit);
Serial.println("Enter 'n' for Next, 'p' for Prev:");
}
uint8_t shadowRAM[32] = {0};
void writeMappedSegment(int aab, bool state) {
int addr = aab / 10;
int bit = aab % 10;
if (state) shadowRAM[addr] |= (1 << bit);
else shadowRAM[addr] &= ~(1 << bit);
writeAddr(addr, shadowRAM[addr]);
}
void displayDigit(const int segments[], int number) {
uint8_t bits = digitMap[number % 10];
for (int i = 0; i < 7; i++) {
// We check Bit 0, then Bit 1, etc.
// This maps i=0 to Segment A, i=1 to Segment B...
bool state = (bits >> i) & 0x01;
writeMappedSegment(segments[i], state);
}
}
void printToRow(int row, long value, int decimalPos = 0) {
const int** currentRow;
int numDigits;
// Select row configuration
switch(row) {
case 1: currentRow = digitsRow1; numDigits = 5; break;
case 2: currentRow = digitsRow2; numDigits = 5; break;
case 3: currentRow = digitsRow3; numDigits = 6; break;
default: return;
}
// Display the number right-aligned
long tempValue = value;
for (int i = numDigits - 1; i >= 0; i--) {
if (tempValue > 0 || i == numDigits - 1) { // Show at least one digit
displayDigit(currentRow[i], tempValue % 10);
tempValue /= 10;
} else {
// Clear leading zeros (all segments off)
for (int s = 0; s < 7; s++) writeMappedSegment(currentRow[i][s], false);
}
}
// Handle Decimal Points (if applicable for that row)
if (decimalPos > 0 && decimalPos <= 3) {
writeMappedSegment(decimals[row-1][decimalPos-1], true);
}
}
void setupDisplay() {
pinMode(LCD_DATA, OUTPUT);
pinMode(LCD_WR, OUTPUT);
pinMode(LCD_CS, OUTPUT);
digitalWrite(LCD_CS, HIGH); // Initialize serial interface [cite: 443]
sendCmd(0x01); // SYS EN [cite: 534]
sendCmd(0x29); // BIAS 1/3, 4 COM [cite: 420, 544]
sendCmd(0x03); // LCD ON [cite: 534]
updateDisplay();
}
const byte ROWS = 4;
const byte COLS = 5;
char keys[ROWS][COLS] = {{'1', '2', '3', 'C', 'T'},
{'4', '5', '6', 'A', 'Z'},
{'7', '8', '9', 'X', 'Y'},
{'0', '.', 'S', 'M', 'L'}};
byte rowPins[ROWS] = {8,9, 10, 11};
byte colPins[COLS] = {12,13,14,15,16};
Keypad kpd = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
void playBeep() {
analogWrite(BUZZER_PIN, 10);
delay(50);
analogWrite(BUZZER_PIN, 0);
}
long tareOffset = 0;
float calibrationFactor =
74.17; // 1.0 by default, callibrated by placing 796 weight and callibrating
long readSD10809() {
long data = 0;
// Wait for DRDY to go LOW
uint32_t timeout = millis();
while (digitalRead(DOUT_SDI0819) == HIGH) {
if (millis() - timeout > 100)
return -1; // 20Hz rate is 50ms
}
// Read 24-bit ADC result [cite: 158, 160]
for (int i = 0; i < 24; i++) {
digitalWrite(SCLK_SDI0819, HIGH);
delayMicroseconds(1);
data = (data << 1) | digitalRead(DOUT_SDI0819);
digitalWrite(SCLK_SDI0819, LOW);
delayMicroseconds(1);
}
// Send 3 extra pulses (Total 27) to keep Channel A at 128x Gain [cite: 152,
// 161]
for (int i = 0; i < 3; i++) {
digitalWrite(SCLK_SDI0819, HIGH);
delayMicroseconds(1);
digitalWrite(SCLK_SDI0819, LOW);
delayMicroseconds(1);
}
// Handle 24-bit Two's Complement sign extension [cite: 108]
if (data & 0x800000)
data |= 0xFF000000;
return data;
}
long getAverageReading(int samples) {
long sum = 0;
int count = 0;
while (count < samples) {
long val = readSD10809();
if (val != -1) {
sum += val;
count++;
}
}
return sum / samples;
}
void tare() {
Serial.println("Taring... keep scale still.");
tareOffset = getAverageReading(20);
Serial.print("New Offset: ");
Serial.println(tareOffset);
}
void calibrate(float knownWeightGrams) {
long currentRaw = getAverageReading(20);
calibrationFactor = (float)(currentRaw - tareOffset) / knownWeightGrams;
Serial.print("Calibration Factor set to: ");
Serial.println(calibrationFactor);
}
void setupADC() {
pinMode(SCLK_SDI0819, OUTPUT);
pinMode(DOUT_SDI0819, INPUT);
// Give the chip time to stabilize (2 cycles for SD10809) [cite: 142]
delay(500);
tare();
}
void setup() {
Serial.begin(115200);
setupDisplay();
setupADC();
}
void loop() {
long raw = readSD10809();
if (kpd.getKeys()) {
for (int i = 0; i < LIST_MAX; i++) {
if (kpd.key[i].stateChanged) {
String msg = "";
switch (kpd.key[i].kstate) {
case PRESSED:
msg = " PRESSED.";
playBeep();
break;
case HOLD:
msg = " HOLD.";
break;
case RELEASED:
msg = " RELEASED.";
break;
case IDLE:
msg = " IDLE.";
break;
}
if (msg != "") {
Serial.print("Key ");
Serial.print(kpd.key[i].kchar);
Serial.println(msg);
}
}
}
}
if (raw != -1) {
float displayWeight = (raw - tareOffset) / calibrationFactor;
Serial.print("Weight: ");
Serial.print(displayWeight, 2);
Serial.println(" g");
}
// Example trigger for calibration via Serial
if (Serial.available()) {
char c = Serial.read();
if (c == 't') {
tare();
}
if (c == 'c') {
calibrate(796);
}
}
}

View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

Binary file not shown.

View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View File

@@ -0,0 +1,15 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:waveshare_rp2040_zero]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = waveshare_rp2040_zero
framework = arduino
monitor_speed = 115200

View File

@@ -0,0 +1,50 @@
#ifndef TM1621_CONFIG_H
#define TM1621_CONFIG_H
#include <Arduino.h>
const uint8_t digitMap[] = {
0b11111100, // 0
0b00000110, // 1
0b01011011, // 2
0b01001111, // 3
0b01100110, // 4
0b01101101, // 5
0b01111101, // 6
0b00000111, // 7
0b01111111, // 8
0b01101111 // 9
};
const int arrows[] = {283,363,183,203}; //tare,?,?,Zero
const int battery[] = {63,83,103}; //LOW / MED / FULL
// Following your pattern: {A, B, C, D, E, F, G}
const int row1_d1[] = {300, 301, 302, 313, 312, 310, 311}; // Addresses 30 & 31
const int row1_d2[] = {280, 281, 282, 293, 292, 290, 291}; // Addresses 28 & 29
const int row1_d3[] = {260, 261, 262, 273, 272, 270, 271}; // Addresses 26 & 27
const int row1_d4[] = {240, 241, 242, 253, 252, 250, 251}; // Addresses 24 & 25
const int row1_d5[] = {220, 221, 222, 233, 232, 230, 231}; // Addresses 22 & 23
const int row1_decimal[] = {263,232,223}; //XX.X.X.X
const int row2_d1[] = {200, 201, 202, 213, 212, 210, 211}; // Addresses 20 & 21
const int row2_d2[] = {180, 181, 182, 193, 192, 190, 191}; // Addresses 18 & 19
const int row2_d3[] = {160, 161, 162, 173, 172, 170, 171}; // Addresses 16 & 17
const int row2_d4[] = {140, 141, 142, 153, 152, 150, 151}; // Addresses 14 & 15
const int row2_d5[] = {120, 121, 122, 133, 132, 130, 131}; // Addresses 12 & 13
const int row2_decimal[] = {163,143,123}; //XX.X.X.X
const int row3_d1[] = {100, 101, 102, 113, 112, 110, 111}; // Addresses 10 & 11
const int row3_d2[] = {80, 81, 82, 93, 92, 90, 91}; // Addresses 8 & 9
const int row3_d3[] = {60, 61, 62, 73, 72, 70, 71}; // Addresses 6 & 7
const int row3_d4[] = {40, 41, 42, 53, 52, 50, 51}; // Addresses 4 & 5
const int row3_d5[] = {20, 21, 22, 33, 32, 30, 31}; // Addresses 2 & 3
const int row3_d6[] = {0, 1, 2, 13, 12, 10, 11}; // Addresses 0 & 1
const int row3_decimal[] = {43,23,3}; //XX.X.X.X
const int* digitsRow1[] = {row1_d1, row1_d2, row1_d3, row1_d4, row1_d5};
const int* digitsRow2[] = {row2_d1, row2_d2, row2_d3, row2_d4, row2_d5};
const int* digitsRow3[] = {row3_d1, row3_d2, row3_d3, row3_d4, row3_d5, row3_d6};
const int* decimals[] = {row1_decimal, row2_decimal, row3_decimal};
#endif

View File

@@ -0,0 +1,175 @@
#include <Arduino.h>
#include "TM1621_Config.h"
#define LCD_DATA 29
#define LCD_WR 28
#define LCD_CS 27
// Tracking variables
int currentAddr = 0;
int currentBit = 0;
void writeBits(uint32_t data, uint8_t count) {
for (int8_t i = count - 1; i >= 0; i--) {
digitalWrite(LCD_WR, LOW);
digitalWrite(LCD_DATA, (data >> i) & 0x01);
digitalWrite(LCD_WR, HIGH);
}
}
void sendCmd(uint8_t cmd) {
digitalWrite(LCD_CS, LOW);
writeBits(0x04, 3); // Binary 100 [cite: 432]
writeBits(cmd, 8); // Command [cite: 413, 534]
writeBits(0, 1); // X bit [cite: 416]
digitalWrite(LCD_CS, HIGH);
}
void writeAddr(uint8_t addr, uint8_t data) {
digitalWrite(LCD_CS, LOW);
uint16_t header = (0x05 << 6) | (addr & 0x3F); // Mode 101 [cite: 475]
writeBits(header, 9);
writeBits(data & 0x0F, 4); // 4 bits of data [cite: 475]
digitalWrite(LCD_CS, HIGH);
}
void updateDisplay() {
// Clear all segments first
for (int i = 0; i < 32; i++) {
writeAddr(i, 0x00);
}
// Light up only the current bit
writeAddr(currentAddr, (1 << currentBit));
Serial.print(">>> CURRENT - Address: ");
Serial.print(currentAddr);
Serial.print(" | Bit (COM): ");
Serial.println(currentBit);
Serial.println("Enter 'n' for Next, 'p' for Prev:");
}
void setup() {
pinMode(LCD_DATA, OUTPUT);
pinMode(LCD_WR, OUTPUT);
pinMode(LCD_CS, OUTPUT);
digitalWrite(LCD_CS, HIGH); // Initialize serial interface [cite: 443]
sendCmd(0x01); // SYS EN [cite: 534]
sendCmd(0x29); // BIAS 1/3, 4 COM [cite: 420, 544]
sendCmd(0x03); // LCD ON [cite: 534]
updateDisplay();
}
uint8_t shadowRAM[32] = {0};
void writeMappedSegment(int aab, bool state) {
int addr = aab / 10;
int bit = aab % 10;
if (state) shadowRAM[addr] |= (1 << bit);
else shadowRAM[addr] &= ~(1 << bit);
writeAddr(addr, shadowRAM[addr]);
}
void displayDigit(const int segments[], int number) {
uint8_t bits = digitMap[number % 10];
for (int i = 0; i < 7; i++) {
// We check Bit 0, then Bit 1, etc.
// This maps i=0 to Segment A, i=1 to Segment B...
bool state = (bits >> i) & 0x01;
writeMappedSegment(segments[i], state);
}
}
void printToRow(int row, long value, int decimalPos = 0) {
const int** currentRow;
int numDigits;
// Select row configuration
switch(row) {
case 1: currentRow = digitsRow1; numDigits = 5; break;
case 2: currentRow = digitsRow2; numDigits = 5; break;
case 3: currentRow = digitsRow3; numDigits = 6; break;
default: return;
}
// Display the number right-aligned
long tempValue = value;
for (int i = numDigits - 1; i >= 0; i--) {
if (tempValue > 0 || i == numDigits - 1) { // Show at least one digit
displayDigit(currentRow[i], tempValue % 10);
tempValue /= 10;
} else {
// Clear leading zeros (all segments off)
for (int s = 0; s < 7; s++) writeMappedSegment(currentRow[i][s], false);
}
}
// Handle Decimal Points (if applicable for that row)
if (decimalPos > 0 && decimalPos <= 3) {
writeMappedSegment(decimals[row-1][decimalPos-1], true);
}
}
int counter1 = 0;
int counter2 = 0;
int counter3 = 0;
void loop() {
printToRow(1, counter1);
printToRow(2, counter2);
printToRow(3, counter3);
counter1 = (counter1 + 1) % 1000;
counter2 = (counter2 + 2) % 1000;
counter3 = (counter3 + 3) % 1000;
delay(100);
}
// void loop() {
// writeAddr(0, 0x00);
// writeAddr(1, 0x00);
// writeAddr(2, 0x00);
// writeAddr(3, 0x00);
// displayDigit(row1_d1, counter);
// Serial.println(counter);
// counter++;
// if (counter > 9) {
// counter = 0;
// }
// delay(1000);
// }
// void loop() {
// if (Serial.available() > 0) {
// char input = Serial.read();
// // Ignore newline/carriage return characters
// if (input == '\n' || input == '\r') return;
// if (input == 'n' || input == 'N') {
// currentBit++;
// if (currentBit > 3) {
// currentBit = 0;
// currentAddr++;
// }
// if (currentAddr > 31) currentAddr = 0;
// updateDisplay();
// }
// else if (input == 'p' || input == 'P') {
// currentBit--;
// if (currentBit < 0) {
// currentBit = 3;
// currentAddr--;
// }
// if (currentAddr < 0) currentAddr = 31;
// updateDisplay();
// }
// }
// }

View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View File

@@ -0,0 +1,16 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:waveshare_rp2040_zero]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = waveshare_rp2040_zero
framework = arduino
monitor_speed = 115200
lib_deps = https://github.com/Chris--A/Keypad

View File

@@ -0,0 +1,70 @@
#include <Arduino.h>
#include <Keypad.h>
#define BUZZER_PIN 9
const byte ROWS = 4;
const byte COLS = 5;
char keys[ROWS][COLS] = {{'1', '2','3','C','T'},
{'4', '5','6','A','Z'},
{'7', '8','9','X','Y'},
{'0', '.','S','M','L'}};
byte rowPins[ROWS] = {0,1,2,3};
byte colPins[COLS] = {4,5,6,7,8};
Keypad kpd = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
unsigned long loopCount = 0;
unsigned long startTime;
void playBeep() {
analogWrite(BUZZER_PIN, 10);
delay(50);
analogWrite(BUZZER_PIN, 0);
}
void setup() {
Serial.begin(115200);
startTime = millis();
}
void loop() {
loopCount++;
if ((millis() - startTime) > 5000) {
Serial.print("Average loops per second = ");
Serial.println(loopCount / 5);
startTime = millis();
loopCount = 0;
}
if (kpd.getKeys()) {
for (int i = 0; i < LIST_MAX; i++) {
if (kpd.key[i].stateChanged) {
String msg = "";
switch (kpd.key[i].kstate) {
case PRESSED:
msg = " PRESSED.";
playBeep();
break;
case HOLD:
msg = " HOLD.";
break;
case RELEASED:
msg = " RELEASED.";
break;
case IDLE:
msg = " IDLE.";
break;
}
if (msg != "") {
Serial.print("Key ");
Serial.print(kpd.key[i].kchar);
Serial.println(msg);
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

Binary file not shown.

View File

@@ -0,0 +1,164 @@
import os
import json
import requests
import barcode
import urllib3
import re
from barcode.writer import ImageWriter
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
# --- SETTINGS ---
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
JSON_FILE = os.path.join(os.getcwd(), 'curated_list.json')
CARD_DIR = os.path.join(os.getcwd(), 'keychain_cards')
IMG_CACHE_DIR = os.path.join(os.getcwd(), 'image_cache')
OUTPUT_PDF = os.path.join(os.getcwd(), 'keychain_3x3_perfect.pdf')
# A4 at 300 DPI
PAGE_W, PAGE_H = 2480, 3508
COLS, ROWS = 3, 3
PAGE_MARGIN = 150
# Ensure directories exist
for d in [CARD_DIR, IMG_CACHE_DIR]:
os.makedirs(d, exist_ok=True)
def clean_filename(name):
return re.sub(r'[\\/*?:"<>|]', "_", name)
def get_ean_from_plu(plu):
return f"000000{str(plu).zfill(4)}00"
def get_cached_image(url, plu):
cache_path = os.path.join(IMG_CACHE_DIR, f"{plu}.jpg")
# Si el archivo ya existe en el cache, lo usamos sin importar la URL
if os.path.exists(cache_path):
return cache_path
# Si no existe y la URL es un placeholder, no podemos descargar nada
if url == "URL_PLACEHOLDER":
print(f"⚠️ {plu} tiene placeholder y no se encontró en {IMG_CACHE_DIR}")
return None
# Lógica de descarga original para URLs reales
try:
headers = {'User-Agent': 'Mozilla/5.0'}
res = requests.get(url, headers=headers, timeout=10, verify=False)
if res.status_code == 200:
with open(cache_path, 'wb') as f:
f.write(res.content)
return cache_path
except Exception as e:
print(f"❌ Error descargando {plu}: {e}")
return None
def generate_card(item):
name = item['name']
plu = item['plu']
img_url = item['image']
safe_name = clean_filename(name).replace(' ', '_')
final_path = os.path.join(CARD_DIR, f"PLU_{plu}_{safe_name}.png")
if os.path.exists(final_path):
return final_path
local_img_path = get_cached_image(img_url, plu)
card = Image.new('RGB', (300, 450), color='white')
draw = ImageDraw.Draw(card)
draw.rectangle([0, 0, 299, 449], outline="black", width=3)
if local_img_path:
try:
img = Image.open(local_img_path).convert("RGB")
w, h = img.size
size = min(w, h)
img = img.crop(((w-size)//2, (h-size)//2, (w+size)//2, (h+size)//2))
img = img.resize((200, 200), Image.Resampling.LANCZOS)
card.paste(img, (50, 40))
except:
draw.text((150, 140), "[IMG ERROR]", anchor="mm", fill="red")
else:
draw.text((150, 140), "[NOT FOUND]", anchor="mm", fill="red")
try:
f_name = ImageFont.truetype("arialbd.ttf", 22)
f_plu = ImageFont.truetype("arial.ttf", 18)
except:
f_name = f_plu = ImageFont.load_default()
draw.text((150, 260), name.upper(), fill="black", font=f_name, anchor="mm")
draw.text((150, 295), f"PLU: {plu}", fill="#333333", font=f_plu, anchor="mm")
EAN = barcode.get_barcode_class('ean13')
ean = EAN(get_ean_from_plu(plu), writer=ImageWriter())
tmp = f"tmp_{plu}"
ean.save(tmp, options={'module_height': 12.0, 'font_size': 10, 'text_distance': 4})
if os.path.exists(f"{tmp}.png"):
b_img = Image.open(f"{tmp}.png")
b_img = b_img.resize((280, 120))
card.paste(b_img, (10, 320))
os.remove(f"{tmp}.png")
card.save(final_path)
print(f" - Card created: {name} ({plu})")
return final_path
def create_pdf():
all_files = sorted([f for f in os.listdir(CARD_DIR) if f.endswith('.png')])
if not all_files:
print("❌ No cards found to put in PDF.")
return
available_w = PAGE_W - (PAGE_MARGIN * 2)
available_h = PAGE_H - (PAGE_MARGIN * 2)
slot_w, slot_h = available_w // COLS, available_h // ROWS
target_w = int(slot_w * 0.9)
target_h = int(target_w * (450 / 300))
if target_h > (slot_h * 0.9):
target_h = int(slot_h * 0.9)
target_w = int(target_h * (300 / 450))
pages = []
current_page = Image.new('RGB', (PAGE_W, PAGE_H), 'white')
print(f"📄 Organizing {len(all_files)} cards into {COLS}x{ROWS} grid...")
for i, filename in enumerate(all_files):
item_idx = i % (COLS * ROWS)
if item_idx == 0 and i > 0:
pages.append(current_page)
current_page = Image.new('RGB', (PAGE_W, PAGE_H), 'white')
row, col = item_idx // COLS, item_idx % COLS
img_path = os.path.join(CARD_DIR, filename)
card_img = Image.open(img_path).convert('RGB')
card_img = card_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
x = PAGE_MARGIN + (col * slot_w) + (slot_w - target_w) // 2
y = PAGE_MARGIN + (row * slot_h) + (slot_h - target_h) // 2
current_page.paste(card_img, (x, y))
pages.append(current_page)
pages[0].save(OUTPUT_PDF, save_all=True, append_images=pages[1:], resolution=300.0, quality=100)
print(f"✅ Created {OUTPUT_PDF}. Now go print it and stop crying.")
if __name__ == "__main__":
if not os.path.exists(JSON_FILE):
print(f"❌ Missing {JSON_FILE}")
else:
with open(JSON_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f"Step 1: Processing {len(data)} cards...")
for entry in data:
generate_card(entry)
print("\nStep 2: Generating PDF...")
create_pdf()

View File

@@ -0,0 +1,51 @@
import pandas as pd
import json
import os
file_path = os.path.join(os.getcwd(), 'PLU+FSMA+list+v1.0.xlsx')
sheet_name = 'Non FTL'
new_url_base = "https://server-ifps.accurateig.com/assets/commodities/"
def get_one_of_each():
if not os.path.exists(file_path):
print("❌ Excel file not found.")
return
# 1. Load Excel
df = pd.read_excel(file_path, sheet_name=sheet_name)
# 2. Drop rows missing the essentials
df = df.dropna(subset=['IMAGE', 'PLU', 'COMMODITY'])
# 3. CRITICAL: Drop duplicates by COMMODITY only
# This ignores Variety and Size, giving us exactly one row per fruit type.
df_unique = df.drop_duplicates(subset=['COMMODITY'], keep='first')
data_output = []
for _, row in df_unique.iterrows():
# Extract filename from the messy URL in Excel
original_link = str(row['IMAGE'])
filename = original_link.split('/')[-1]
# Build the final working URL
image_url = f"{new_url_base}{filename}"
# Get the clean Commodity name
commodity = str(row['COMMODITY']).title()
plu_code = str(row['PLU'])
data_output.append({
"name": commodity,
"plu": plu_code,
"image": image_url
})
# 4. Save to JSON
with open('one_of_each.json', 'w', encoding='utf-8') as f:
json.dump(data_output, f, indent=4, ensure_ascii=False)
print(f"✅ Success! Generated 'one_of_each.json' with {len(data_output)} unique commodities.")
if __name__ == "__main__":
get_one_of_each()

View File

@@ -0,0 +1,36 @@
import serial
import requests
import time
import argparse
import sys
def run_bridge():
parser = argparse.ArgumentParser(description="Scanner Bridge for the technically impaired")
parser.add_argument('--port', default='COM5', help='Serial port (default: COM5)')
parser.add_argument('--baud', type=int, default=115200, help='Baud rate (default: 115200)')
parser.add_argument('--url', default='https://scanner.sekidesu.xyz/scan', help='Server URL')
args = parser.parse_args()
try:
ser = serial.Serial(args.port, args.baud, timeout=0.1)
print(f"Connected to {args.port} at {args.baud} bauds.")
while True:
if ser.in_waiting > 0:
barcode = ser.readline().decode('utf-8', errors='ignore').strip()
if barcode:
print(f"Scanned: {barcode}")
try:
resp = requests.get(args.url, params={'content': barcode}, timeout=5)
print(f"Server responded: {resp.status_code}")
except Exception as e:
print(f"Failed to send to server: {e}")
time.sleep(0.01)
except serial.SerialException as e:
print(f"Error opening {args.port}: {e}")
except KeyboardInterrupt:
print("\nBridge stopped. Finally.")
if __name__ == "__main__":
run_bridge()

View File

@@ -0,0 +1,155 @@
import tkinter as tk
from tkinter import ttk
import threading
import requests
import usb.core
import usb.util
import usb.backend.libusb1
import os
import time
VENDOR_ID = 0xFFFF
PRODUCT_ID = 0x0035
HID_MAP = {
4: 'a', 5: 'b', 6: 'c', 7: 'd', 8: 'e', 9: 'f', 10: 'g', 11: 'h', 12: 'i',
13: 'j', 14: 'k', 15: 'l', 16: 'm', 17: 'n', 18: 'o', 19: 'p', 20: 'q',
21: 'r', 22: 's', 23: 't', 24: 'u', 25: 'v', 26: 'w', 27: 'x', 28: 'y', 29: 'z',
30: '1', 31: '2', 32: '3', 33: '4', 34: '5', 35: '6', 36: '7', 37: '8', 38: '9', 39: '0',
44: ' ', 45: '-', 46: '=', 55: '.', 56: '/'
}
class POSBridgeApp:
def __init__(self, root):
self.root = root
self.root.title("POS Hardware Bridge")
self.root.geometry("500x320")
self.running = True
# UI Setup
ttk.Label(root, text="Target POS Endpoint:").pack(pady=(15, 2))
self.url_var = tk.StringVar(value="https://scanner.sekidesu.xyz/scan")
self.url_entry = ttk.Entry(root, textvariable=self.url_var, width=60)
self.url_entry.pack(pady=5)
self.status_var = tk.StringVar(value="Status: Booting...")
self.status_label = ttk.Label(root, textvariable=self.status_var, font=("Segoe UI", 10, "bold"))
self.status_label.pack(pady=10)
ttk.Label(root, text="Activity Log:").pack()
self.log_listbox = tk.Listbox(root, width=70, height=8, bg="#1e1e1e", fg="#00ff00", font=("Consolas", 9))
self.log_listbox.pack(pady=5, padx=10)
# Bind the close button to kill threads cleanly
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
# Fire up the USB listener in a background thread
self.usb_thread = threading.Thread(target=self.usb_listen_loop, daemon=True)
self.usb_thread.start()
def log(self, message):
# Tkinter requires GUI updates to happen on the main thread
self.root.after(0, self._append_log, message)
def _append_log(self, message):
self.log_listbox.insert(0, time.strftime("[%H:%M:%S] ") + message)
if self.log_listbox.size() > 15:
self.log_listbox.delete(15)
def update_status(self, text, color="black"):
self.root.after(0, self._set_status, text, color)
def _set_status(self, text, color):
self.status_var.set(f"Status: {text}")
self.status_label.config(foreground=color)
def on_close(self):
self.running = False
self.root.destroy()
def send_to_pos(self, barcode):
url = self.url_var.get()
self.log(f"Captured: {barcode}. Sending...")
try:
resp = requests.get(url, params={'content': barcode}, timeout=3)
self.log(f"Success: POS returned {resp.status_code}")
except requests.RequestException as e:
self.log(f"HTTP Error: Backend unreachable")
def usb_listen_loop(self):
import sys
# PyInstaller extracts files to a temp _MEIPASS folder at runtime
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
dll_path = os.path.join(base_path, "libusb-1.0.dll")
if not os.path.exists(dll_path):
self.update_status(f"CRITICAL: DLL missing at {dll_path}", "red")
return
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
while self.running:
# Reconnect loop
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=backend)
if dev is None:
self.update_status("Scanner unplugged. Waiting...", "red")
time.sleep(2)
continue
try:
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0,0)]
endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
)
self.update_status("Scanner Locked & Ready", "green")
current_barcode = ""
# Active reading loop
while self.running:
try:
data = dev.read(endpoint.bEndpointAddress, endpoint.wMaxPacketSize, timeout=1000)
keycode = data[2]
modifier = data[0]
is_shift = modifier == 2 or modifier == 32
if keycode == 0:
continue
if keycode == 40: # Enter key signifies end of scan
if current_barcode:
# Spawn a micro-thread for the HTTP request so we don't block the next scan
threading.Thread(target=self.send_to_pos, args=(current_barcode,), daemon=True).start()
current_barcode = ""
elif keycode in HID_MAP:
char = HID_MAP[keycode]
if is_shift and char.isalpha():
char = char.upper()
current_barcode += char
except usb.core.USBError as e:
# 10060/110 are normal timeouts when no barcode is being actively scanned
if e.args[0] in (10060, 110):
continue
else:
self.log(f"Hardware interrupt lost. Reconnecting...")
break # Breaks inner loop to trigger outer reconnect loop
except Exception as e:
self.log(f"USB Error: {e}")
time.sleep(2) # Prevent rapid crash loops
if __name__ == '__main__':
# You must run pip install requests if you haven't already
root = tk.Tk()
app = POSBridgeApp(root)
root.mainloop()

Binary file not shown.

View File

@@ -0,0 +1,25 @@
import usb.core
import usb.backend.libusb1
import os
# Grab the exact path to the DLL in your current folder
current_dir = os.path.dirname(os.path.abspath(__file__))
dll_path = os.path.join(current_dir, "libusb-1.0.dll")
if not os.path.exists(dll_path):
print(f"I don't see the DLL at: {dll_path}")
exit(1)
# Force pyusb to use this specific file
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
print("Scanning with forced local DLL backend...")
devices = usb.core.find(find_all=True, backend=backend)
found = False
for d in devices:
found = True
print(f"Found Device -> VID: {hex(d.idVendor)} PID: {hex(d.idProduct)}")
if not found:
print("Python is still blind. The DLL might be the wrong architecture (32-bit vs 64-bit).")

View File

@@ -0,0 +1,85 @@
import usb.core
import usb.util
import usb.backend.libusb1
import os
import sys
# Your exact scanner IDs
VENDOR_ID = 0xFFFF
PRODUCT_ID = 0x0035
# Basic HID to ASCII translation dictionary
HID_MAP = {
4: 'a', 5: 'b', 6: 'c', 7: 'd', 8: 'e', 9: 'f', 10: 'g', 11: 'h', 12: 'i',
13: 'j', 14: 'k', 15: 'l', 16: 'm', 17: 'n', 18: 'o', 19: 'p', 20: 'q',
21: 'r', 22: 's', 23: 't', 24: 'u', 25: 'v', 26: 'w', 27: 'x', 28: 'y', 29: 'z',
30: '1', 31: '2', 32: '3', 33: '4', 34: '5', 35: '6', 36: '7', 37: '8', 38: '9', 39: '0',
44: ' ', 45: '-', 46: '=', 55: '.', 56: '/'
}
def main():
# Force the local DLL backend
current_dir = os.path.dirname(os.path.abspath(__file__))
dll_path = os.path.join(current_dir, "libusb-1.0.dll")
if not os.path.exists(dll_path):
print(f"Error: Missing {dll_path}")
sys.exit(1)
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
# Find the scanner using the forced backend
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=backend)
if dev is None:
print("Scanner not found. Check Zadig driver again.")
sys.exit(1)
# Claim device
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0,0)]
endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
)
print("Scanner locked. Waiting for barcodes...")
current_barcode = ""
while True:
try:
# Read 8 bytes from the scanner
data = dev.read(endpoint.bEndpointAddress, endpoint.wMaxPacketSize, timeout=1000)
keycode = data[2]
modifier = data[0]
is_shift = modifier == 2 or modifier == 32
if keycode == 0:
continue
if keycode == 40: # Enter key
print(f"Captured Barcode: {current_barcode}")
current_barcode = ""
elif keycode in HID_MAP:
char = HID_MAP[keycode]
if is_shift and char.isalpha():
char = char.upper()
current_barcode += char
except usb.core.USBError as e:
if e.args[0] == 10060 or e.args[0] == 110:
continue
else:
print(f"USB Error: {e}")
break
except KeyboardInterrupt:
print("\nExiting and releasing scanner...")
usb.util.dispose_resources(dev)
break
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,29 @@
import sqlite3
DB_FILE = 'db/pos_database.db'
def upgrade_db():
try:
with sqlite3.connect(DB_FILE) as conn:
<<<<<<< HEAD
=======
# Add stock column
# conn.execute("ALTER TABLE products ADD COLUMN stock REAL DEFAULT 0")
# print("Successfully added 'stock' column.")
# # App.py also expects unit_type, adding it to prevent future headaches
# conn.execute("ALTER TABLE products ADD COLUMN unit_type TEXT DEFAULT 'unit'")
# print("Successfully added 'unit_type' column.")
>>>>>>> 1a048a0e074ee26bd45dda9731c78c2ecef42fba
conn.execute("ALTER TABLE dicom ADD COLUMN image_url TEXT;")
print("Successfully added 'image_url' column.")
conn.commit()
print("Migration complete. Your data is intact.")
except sqlite3.OperationalError as e:
print(f"Skipped: {e}. (This usually means the columns already exist, so you're fine).")
if __name__ == '__main__':
upgrade_db()

View File

@@ -3,3 +3,5 @@ Flask-Login==0.6.3
Flask-SocketIO==5.6.1
requests==2.32.5
eventlet==0.36.1
python-dotenv==1.2.2
pywebview==6.2.1

17
static/cookieStuff.js Normal file
View File

@@ -0,0 +1,17 @@
function setCookie(name, value, days = 365) {
const d = new Date();
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
let expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax";
}
function getCookie(name) {
let nameEQ = name + "=";
let ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}

BIN
static/doom.zip Normal file

Binary file not shown.

BIN
static/doom2/DOOM.EXE Normal file

Binary file not shown.

BIN
static/doom2/DOOM.WAD Normal file

Binary file not shown.

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

286
static/style.css Normal file
View File

@@ -0,0 +1,286 @@
:root {
--bg: #ebedef;
--card-bg: #ffffff;
--text-main: #2e3338;
--text-muted: #4f5660;
--border: #e3e5e8;
--navbar-bg: #ffffff;
--input-bg: #e3e5e8;
--table-head: #f2f3f5;
--accent: #5865f2;
--accent-hover: #4752c4;
--danger: #ed4245;
}
html, body {
background-color: var(--bg);
transition: background-color 0.15s ease, color 0.15s ease;
}
/* Prevent theme flash on initial load */
html[data-theme="dark"] body,
html[data-theme="dark"] {
background-color: #36393f;
color: #dcddde;
}
[data-theme="dark"] {
--bg: #36393f;
--card-bg: #2f3136;
--text-main: #dcddde;
--text-muted: #b9bbbe;
--border: #202225;
--navbar-bg: #202225;
--input-bg: #202225;
--table-head: #292b2f;
}
[data-theme="dark"] .table {
--bs-table-color: var(--text-main);
color: var(--text-main);
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
[data-theme="dark"] .modal-body .text-muted {
color: #f6f6f7 !important;
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
[data-theme="dark"] .form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dcddde' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
}
[data-theme="dark"] .modal-content {
background-color: var(--card-bg);
border-color: var(--border);
}
.rounded-circle {
border: 1px solid var(--border);
}
body {
background: var(--bg);
color: var(--text-main);
font-family: "gg sans", "Segoe UI", sans-serif;
transition: background 0.2s, color 0.2s;
}
.navbar {
background: var(--navbar-bg) !important;
border-bottom: 1px solid var(--border);
}
.navbar-brand {
color: var(--text-main) !important;
font-weight: 700;
}
.nav-link,
.dropdown-item {
color: var(--text-main) !important;
}
.dropdown-menu {
background: var(--card-bg);
border: 1px solid var(--border);
}
.dropdown-item:hover {
background: var(--input-bg);
}
.dropdown-item.text-danger {
color: var(--danger) !important;
}
.discord-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-control,
.form-control:focus {
background: var(--input-bg);
color: var(--text-main);
border: none;
box-shadow: none;
}
.form-control:focus {
outline: 2px solid var(--accent);
}
.form-control::placeholder {
color: var(--text-muted);
}
.btn-accent {
background: var(--accent);
color: #fff;
border: none;
}
.btn-accent:hover {
background: var(--accent-hover);
color: #fff;
}
.btn-danger-discord {
background: var(--danger);
color: #fff;
border: none;
}
.btn-danger-discord:hover {
background: #c23235;
color: #fff;
}
.price-tag {
font-size: 2.8rem;
font-weight: 800;
color: var(--accent);
}
.table {
color: var(--text-main);
--bs-table-color: var(--text-main);
--bs-table-bg: transparent;
--bs-table-border-color: var(--border);
}
#select-all {
transform: scale(1.3);
margin-top: 2px;
}
.table thead th {
background: var(--table-head);
color: var(--text-muted);
font-size: 0.72rem;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.table tbody td {
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
/* ── Bulk bar ── */
.bulk-bar {
background: var(--accent);
color: #fff;
border-radius: 8px;
}
.bulk-bar .form-control {
width: 110px;
background: rgba(0, 0, 0, 0.2) !important;
color: #fff !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
}
.bulk-bar .form-control::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.new-product-prompt {
background: var(--accent);
color: #fff;
border-radius: 8px;
}
#display-img {
width: 100%;
max-width: 250px;
height: auto;
max-height: 250px;
object-fit: contain;
}
input[type="checkbox"] {
cursor: pointer;
}
@media (max-width: 576px) {
.col-barcode {
display: none;
}
.btn-edit-sm,
.btn-del-sm {
padding: 4px 7px;
font-size: 0.75rem;
}
}
.modal-content {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--border);
}
.modal-header,
.modal-footer {
border-color: var(--border);
}
.btn-close {
filter: var(--bs-theme-placeholder, invert(0.7) grayscale(100%) brightness(200%));
}
.form-select {
background-color: var(--input-bg) !important;
color: var(--text-main) !important;
border: none !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23adb5bd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
background-repeat: no-repeat !important;
background-position: right 0.75rem center !important;
background-size: 16px 12px !important;
}
.login-box {
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.btn-login {
background: var(--accent);
color: #fff;
font-weight: 600;
padding: 10px;
border: none;
}
.btn-login:hover {
background: var(--accent-hover);
color: #fff;
}
.error-alert {
background: var(--danger);
color: #fff;
border-radius: 4px;
font-size: 0.9rem;
}

39
static/themeStuff.js Normal file
View File

@@ -0,0 +1,39 @@
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
const isDark = (t === 'dark');
const themeIcon = document.getElementById('theme-icon');
const themeLabel = document.getElementById('theme-label');
if (themeIcon) {
themeIcon.className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
}
if (themeLabel) {
themeLabel.innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
function initTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
applyTheme(savedTheme);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark ? 'dark' : 'light');
}
}
// Listen for system theme changes only if the user hasn't set a manual override
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
initTheme();

1699
templates/checkout.html Normal file

File diff suppressed because it is too large Load Diff

589
templates/dicom.html Normal file
View File

@@ -0,0 +1,589 @@
{% extends "macros/base.html" %}
{% block title %}Dicom{% endblock %}
{% block content %}
<style>
.debtor-item {
background: var(--card-bg);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
transition: all 0.2s ease;
border: 1px solid rgba(255,255,255,0.05);
}
.debtor-item:hover {
background: var(--input-bg);
border-color: rgba(255,255,255,0.1);
}
.debtor-item .debtor-avatar {
width: 48px;
height: 48px;
min-width: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.debtor-name {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-main);
}
.debtor-contact {
font-size: 0.85rem;
color: var(--text-muted);
}
.debtor-debt {
font-size: 1.3rem;
font-weight: 700;
color: var(--danger);
}
.debtor-debt.paid {
color: var(--success);
}
.ticket-card {
background: var(--card-bg);
border-radius: 10px;
padding: 16px;
margin-bottom: 10px;
border: 1px solid rgba(255,255,255,0.05);
transition: all 0.2s ease;
}
.ticket-card:hover {
border-color: rgba(255,255,255,0.15);
}
.ticket-status {
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-status.paid { background: rgba(40, 167, 69, 0.2); color: #28a745; }
.ticket-status.partial { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
.ticket-status.unpaid { background: rgba(220, 53, 69, 0.2); color: #dc3545; }
.ticket-item-row {
padding: 6px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.ticket-item-row:last-child { border-bottom: none; }
.chevron-icon {
transition: transform 0.3s ease;
font-size: 1.1rem;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
.btn-pay-all {
font-size: 0.8rem;
padding: 6px 14px;
border-radius: 8px;
font-weight: 600;
}
.btn-delete-debtor {
width: 36px;
height: 36px;
padding: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
opacity: 0.3;
}
</style>
<div class="row g-3">
<div class="col-12">
<div class="discord-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-people me-2"></i>Deudores</h4>
<span class="badge bg-secondary">{{ debtors|length }} registrados</span>
</div>
<div id="debtors-list">
{% for d in debtors %}
<div class="debtor-item" data-debtor-id="{{ d[0] }}">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3 flex-grow-1 cursor-pointer" onclick="toggleDebtor({{ d[0] }})">
<div class="debtor-avatar {% if d[3] > 0 %}bg-danger{% else %}bg-success{% endif %} text-white">
<i class="bi bi-person-fill"></i>
</div>
<div class="flex-grow-1">
<div class="debtor-name">{{ d[1] }}</div>
<div class="debtor-contact">{{ d[2] or 'Sin contacto' }}</div>
</div>
</div>
<div class="text-end me-3">
<div class="debtor-debt {% if d[3] <= 0 %}paid{% endif %} price-cell" data-value="{{ d[3] }}"></div>
<small class="text-muted">{% if d[3] > 0 %}Deuda pendiente{% else %}Saldo cero{% endif %}</small>
</div>
<div class="d-flex align-items-center gap-2">
{% if d[3] > 0 %}
<button class="btn btn-sm btn-success btn-pay-all" onclick="payAllDebtor({{ d[0] }}, {{ d[3] }})" title="Pagar toda la deuda">
<i class="bi bi-check-lg me-1"></i>Pagar Todo
</button>
{% endif %}
<button class="btn btn-sm btn-outline-danger btn-delete-debtor" onclick="deleteDebtor({{ d[0] }}, '{{ d[1] }}')" title="Eliminar deudor">
<i class="bi bi-trash"></i>
</button>
<i class="bi bi-chevron-down text-muted chevron-icon" id="chevron-{{ d[0] }}"></i>
</div>
</div>
<!-- Expanded tickets view -->
<div id="debtor-{{ d[0] }}" class="d-none mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
<div id="tickets-container-{{ d[0] }}" class="text-center text-muted py-3">
<span class="spinner-border spinner-border-sm me-2"></span>Cargando tickets...
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-emoji-frown d-block mb-3"></i>
<p class="mb-0">No hay deudores registrados</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Payment Modal -->
<div class="modal fade" id="paymentModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">
Pagar Deuda
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-4">
<span class="text-muted small">Total a Pagar:</span><br>
<span id="payment-remaining-display" class="fs-1 fw-bold" style="color: var(--accent)">$0</span>
</div>
<div class="d-grid gap-3 px-3">
<button class="btn btn-lg btn-success py-3" onclick="confirmPayment('efectivo')">
<i class="bi bi-cash-coin me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Efectivo (1)
</button>
<button class="btn btn-lg btn-secondary py-3" onclick="confirmPayment('tarjeta')">
<i class="bi bi-credit-card me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Tarjeta (2)
</button>
<button class="btn btn-lg btn-info py-3 text-white" onclick="confirmPayment('transferencia')">
<i class="bi bi-bank me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Transferencia (3)
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Vuelto Modal -->
<div class="modal" id="dicomVueltoModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">Pago en Efectivo</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-3">
<span class="text-muted small">Total a Pagar:</span><br>
<span id="dicom-vuelto-total" class="fs-4 fw-bold" style="color: var(--text-main)">$0</span>
</div>
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Monto Recibido</label>
<input type="text" inputmode="numeric" id="dicom-monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : ''; calculateDicomVuelto();">
</div>
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(1000)">$1.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(2000)">$2.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(5000)">$5.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(10000)">$10.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(20000)">$20.000</button>
</div>
<div class="p-3 mb-3" style="background: var(--input-bg); border-radius: 8px">
<span class="text-muted small text-uppercase fw-bold">Vuelto a Entregar</span><br>
<span id="dicom-vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
</div>
<button id="btn-confirm-dicom-vuelto" class="btn btn-success w-100 py-3 fw-bold" onclick="confirmDicomPayment()" disabled>Confirmar Pago</button>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="dicomSuccessModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-success">
<div class="modal-body text-center py-4">
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
<h4 class="mt-3">¡Pago Exitoso!</h4>
<p class="text-muted">El pago se ha procesado correctamente.</p>
<button class="btn btn-accent px-5" data-bs-dismiss="modal">Listo</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="dicomDeleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header pb-0 border-0">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
<h4 class="mb-3" id="dicom-delete-title">¿Eliminar?</h4>
<p class="text-muted small px-3" id="dicom-delete-desc">Esta acción no se puede deshacer.</p>
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger w-50" id="dicom-delete-confirm-btn">Eliminar</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let currentDebtorId = null;
let currentTicketId = null;
let expandedDebtorId = null;
let payAllMode = false;
let pendingPaymentMethod = null;
let pendingPaymentAmount = 0;
let deleteCallback = null;
const deleteConfirmBtn = document.getElementById('dicom-delete-confirm-btn');
if (deleteConfirmBtn) {
deleteConfirmBtn.addEventListener('click', function() {
if (deleteCallback) {
deleteCallback();
deleteCallback = null;
}
bootstrap.Modal.getInstance(document.getElementById('dicomDeleteModal')).hide();
});
}
function showDeleteConfirm(title, desc, callback) {
const titleEl = document.getElementById('dicom-delete-title');
const descEl = document.getElementById('dicom-delete-desc');
const modalEl = document.getElementById('dicomDeleteModal');
if (titleEl) titleEl.textContent = title;
if (descEl) descEl.textContent = desc;
deleteCallback = callback;
if (modalEl) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
// Format price cells on page load
document.querySelectorAll('.price-cell').forEach(el => {
const val = parseFloat(el.dataset.value) || 0;
el.innerText = clp.format(val);
});
function toggleDebtor(debtorId) {
const container = document.getElementById(`debtor-${debtorId}`);
const chevron = document.getElementById(`chevron-${debtorId}`);
if (container.classList.contains('d-none')) {
container.classList.remove('d-none');
chevron.classList.add('rotated');
expandedDebtorId = debtorId;
loadTickets(debtorId);
} else {
container.classList.add('d-none');
chevron.classList.remove('rotated');
expandedDebtorId = null;
}
}
async function loadTickets(debtorId) {
const container = document.getElementById(`tickets-container-${debtorId}`);
try {
const res = await fetch(`/api/dicom/debtor/${debtorId}`);
const tickets = await res.json();
if (tickets.length === 0) {
container.innerHTML = '<div class="text-muted small py-3">No hay tickets registrados</div>';
return;
}
container.innerHTML = tickets.map(ticket => `
<div class="ticket-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<span class="ticket-status ${ticket.status}">
${ticket.status === 'paid' ? 'Pagado' : ticket.status === 'partial' ? 'Parcial' : 'Pendiente'}
</span>
<span class="text-muted small">${new Date(ticket.date).toLocaleDateString('es-CL', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="text-end me-2">
<div class="fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.total)}</div>
<small class="text-muted">Pagado: ${clp.format(ticket.amount_paid)}</small>
</div>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTicket(${ticket.id})" title="Eliminar ticket" style="border-radius: 8px; width: 34px; height: 34px; padding: 0; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- Items -->
<div style="background: var(--input-bg); border-radius: 8px; padding: 12px 16px;">
${ticket.items.map(item => `
<div class="ticket-item-row d-flex justify-content-between align-items-center">
<div>
<span class="fw-semibold">${item.qty}x</span>
<span class="ms-2">${item.name}</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted">${clp.format(item.subtotal)}</span>
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="Eliminar" style="border-radius: 6px; width: 24px; height: 24px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 0.75rem;">
<i class="bi bi-x"></i>
</button>
</div>
</div>
`).join('')}
</div>
${ticket.remaining > 0 ? `
<div class="d-flex justify-content-between align-items-center mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
<div>
<span class="text-muted small d-block">Restante</span>
<span class="text-danger fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.remaining)}</span>
</div>
<button class="btn btn-sm btn-success btn-pay-all" onclick="openPaymentModal(${ticket.id}, ${ticket.remaining})">
<i class="bi bi-check-lg me-1"></i>Pagar Todo
</button>
</div>
` : '<div class="text-center mt-3"><span class="badge bg-success px-3 py-2"><i class="bi bi-check-circle me-1"></i>Pagado completamente</span></div>'}
</div>
`).join('');
} catch (e) {
console.error(e);
container.innerHTML = '<div class="text-danger small py-3">Error al cargar tickets</div>';
}
}
function openPaymentModal(ticketId, remaining) {
currentTicketId = ticketId;
payAllMode = false;
pendingPaymentAmount = remaining;
document.getElementById('payment-remaining-display').innerText = clp.format(remaining);
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
}
function openPayAllModal(debtorId, totalDebt) {
currentDebtorId = debtorId;
payAllMode = true;
pendingPaymentAmount = totalDebt;
document.getElementById('payment-remaining-display').innerText = clp.format(totalDebt);
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
}
// Focus monto recibido when vuelto modal opens
document.getElementById('dicomVueltoModal').addEventListener('shown.bs.modal', function() {
const input = document.getElementById('dicom-monto-recibido');
input.value = '';
setTimeout(() => input.focus(), 100);
});
// Enter key to confirm payment in vuelto modal
document.getElementById('dicom-monto-recibido').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !document.getElementById('btn-confirm-dicom-vuelto').disabled) {
confirmDicomPayment();
}
});
// Keyboard shortcuts for payment modal
document.addEventListener('keydown', function(e) {
const modal = document.getElementById('paymentModal');
if (modal.classList.contains('show')) {
if (e.key === '1') confirmPayment('efectivo');
if (e.key === '2') confirmPayment('tarjeta');
if (e.key === '3') confirmPayment('transferencia');
}
});
// Store remaining value for calculation
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-value') {
const el = mutation.target;
el.innerText = clp.format(parseFloat(el.dataset.value));
}
});
});
document.querySelectorAll('.price-cell').forEach(el => {
observer.observe(el, { attributes: true });
});
// Delete debtor
function deleteDebtor(debtorId, debtorName) {
showDeleteConfirm(
'Eliminar deudor',
`¿Eliminar al deudor "${debtorName}" y todos sus tickets? Esta acción no se puede deshacer.`,
async function() {
try {
const res = await fetch(`/api/dicom/debtor/${debtorId}`, { method: 'DELETE' });
if (res.ok) {
location.reload();
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Delete ticket
function deleteTicket(ticketId) {
showDeleteConfirm(
'Eliminar ticket',
'¿Eliminar este ticket y todos sus productos? Esta acción no se puede deshacer.',
async function() {
try {
const res = await fetch(`/api/dicom/ticket/${ticketId}`, { method: 'DELETE' });
if (res.ok) {
if (expandedDebtorId) {
loadTickets(expandedDebtorId);
}
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Delete individual item
function deleteItem(itemId) {
showDeleteConfirm(
'Eliminar producto',
'¿Eliminar este producto del ticket? Esta acción no se puede deshacer.',
async function() {
try {
const res = await fetch(`/api/dicom/item/${itemId}`, { method: 'DELETE' });
const data = await res.json();
if (res.ok) {
if (expandedDebtorId) {
loadTickets(expandedDebtorId);
}
} else {
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Pay all tickets for a debtor
function payAllDebtor(debtorId, totalDebt) {
openPayAllModal(debtorId, totalDebt);
}
async function confirmPayment(paymentMethod) {
const amount = pendingPaymentAmount;
// For efectivo, open the vuelto modal
if (paymentMethod === 'efectivo') {
pendingPaymentMethod = paymentMethod;
document.getElementById('dicom-vuelto-total').innerText = clp.format(amount);
document.getElementById('dicom-monto-recibido').value = '';
document.getElementById('dicom-vuelto-amount').innerText = '$0';
document.getElementById('btn-confirm-dicom-vuelto').disabled = true;
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomVueltoModal')).show();
return;
}
// For other methods, process directly
await processDicomPayment(amount, paymentMethod);
}
function setDicomVuelto(amount) {
const formatted = amount.toLocaleString('es-CL');
document.getElementById('dicom-monto-recibido').value = formatted;
calculateDicomVuelto();
}
function calculateDicomVuelto() {
const receivedStr = document.getElementById('dicom-monto-recibido').value;
const received = parseInt(receivedStr.replace(/\./g, '')) || 0;
const total = pendingPaymentAmount;
const change = Math.max(0, received - total);
document.getElementById('dicom-vuelto-amount').innerText = clp.format(change);
document.getElementById('btn-confirm-dicom-vuelto').disabled = received < total;
}
async function confirmDicomPayment() {
await processDicomPayment(pendingPaymentAmount, pendingPaymentMethod);
bootstrap.Modal.getInstance(document.getElementById('dicomVueltoModal')).hide();
}
async function processDicomPayment(amount, paymentMethod) {
try {
let res;
if (payAllMode) {
res = await fetch(`/api/dicom/debtor/${currentDebtorId}/pay-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: amount, payment_method: paymentMethod })
});
} else {
res = await fetch(`/api/dicom/pay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket_id: currentTicketId, amount: amount, payment_method: paymentMethod })
});
}
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
payAllMode = false;
// Show success modal
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomSuccessModal')).show();
// Reload page after modal hides
setTimeout(() => location.reload(), 2500);
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
</script>
{% endblock %}

228
templates/gastos.html Normal file
View File

@@ -0,0 +1,228 @@
{% extends "macros/base.html" %}
{% block title %}Gastos y Utilidad{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h3 class="mb-0"><i class="bi bi-wallet2 me-2"></i>Gastos y Utilidad</h3>
<div class="d-flex gap-2">
<select id="month-select" class="form-select form-select-sm"
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
onchange="applyDateFilter()">
</select>
<select id="year-select" class="form-select form-select-sm"
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
onchange="applyDateFilter()">
</select>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center border-success" style="border-bottom: 4px solid #198754;">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Ventas Totales</h6>
<h2 class="price-cell mb-0 text-success" style="font-weight: 800;" data-value="{{ sales_total }}"></h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center border-danger" style="border-bottom: 4px solid #dc3545;">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Gastos Totales</h6>
<h2 class="price-cell mb-0 text-danger" style="font-weight: 800;" data-value="{{ expenses_total }}"></h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center" style="border-bottom: 4px solid {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %};">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Utilidad Neta</h6>
<h2 class="price-cell mb-0" style="color: {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %}; font-weight: 800;" data-value="{{ net_profit }}"></h2>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="discord-card p-3 shadow-sm">
<h5 class="mb-3"><i class="bi bi-plus-circle me-2"></i>Registrar Gasto</h5>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Descripción</label>
<input type="text" id="gasto-desc" class="form-control" placeholder="Ej: Pago de luz, Mercadería..." autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Monto (CLP)</label>
<input type="text" inputmode="numeric" id="gasto-monto" class="form-control fs-5 fw-bold"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';"
onkeydown="if(event.key === 'Enter') submitGasto()">
</div>
<button class="btn btn-warning w-100 fw-bold" onclick="submitGasto()">
<i class="bi bi-save me-1"></i> Guardar Gasto
</button>
</div>
</div>
<div class="col-md-8">
<div class="discord-card p-3 shadow-sm">
<h5 class="mb-3"><i class="bi bi-list-ul me-2"></i>Historial de Gastos</h5>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th class="text-end">Monto</th>
<th style="width: 1%;"></th>
</tr>
</thead>
<tbody>
{% if expenses %}
{% for e in expenses %}
<tr>
<td class="utc-date text-muted">{{ e[1] }}</td>
<td>{{ e[2] }}</td>
<td class="text-end text-danger fw-bold price-cell" data-value="{{ e[3] }}"></td>
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-danger py-0 px-2" onclick="deleteGasto({{ e[0] }})" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No hay gastos registrados en este mes.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deleteGastoModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header border-0 pb-0">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
<h4 class="mb-3">¿Eliminar Gasto?</h4>
<p class="text-muted px-3">Esta acción eliminará el registro permanentemente y recalculará la utilidad neta.</p>
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger w-50" onclick="executeDeleteGasto()">Sí, Eliminar</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let gastoToDelete = null;
// Format UI numbers
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
document.querySelectorAll('.utc-date').forEach(el => {
const date = new Date(el.innerText + " UTC");
if (!isNaN(date)) {
el.innerText = date.toLocaleString('es-CL', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
});
// --- Build the Split Dropdowns ---
const currentSelected = "{{ selected_month }}"; // Comes from backend as "YYYY-MM"
const [selYear, selMonth] = currentSelected.split('-');
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const monthNames = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"];
// Populate Months
monthNames.forEach((name, index) => {
const val = String(index + 1).padStart(2, '0');
const option = document.createElement('option');
option.value = val;
option.innerText = name;
if (val === selMonth) option.selected = true;
monthSelect.appendChild(option);
});
// Populate Years (Current year +/- a few years)
const currentYear = new Date().getFullYear();
for (let y = currentYear - 3; y <= currentYear + 1; y++) {
const option = document.createElement('option');
option.value = y;
option.innerText = y;
if (y.toString() === selYear) option.selected = true;
yearSelect.appendChild(option);
}
// Trigger URL change when either dropdown is touched
function applyDateFilter() {
const m = monthSelect.value;
const y = yearSelect.value;
window.location.href = `/gastos?month=${y}-${m}`;
}
async function submitGasto() {
const descInput = document.getElementById('gasto-desc');
const montoInput = document.getElementById('gasto-monto');
const desc = descInput.value.trim();
const rawMonto = montoInput.value.replace(/\./g, '');
const amount = parseInt(rawMonto, 10);
if (!desc || isNaN(amount) || amount <= 0) {
alert("Por favor ingresa una descripción y un monto válido.");
return;
}
try {
const res = await fetch('/api/gastos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: desc, amount: amount })
});
if (res.ok) {
descInput.value = '';
montoInput.value = '';
window.location.href = window.location.pathname + window.location.search;
} else {
alert("Error al guardar el gasto.");
}
} catch (err) {
alert("Error de conexión.");
}
}
// Open Modal
function deleteGasto(id) {
gastoToDelete = id;
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteGastoModal')).show();
}
// Execute the backend call
async function executeDeleteGasto() {
if (!gastoToDelete) return;
try {
const res = await fetch(`/api/gastos/${gastoToDelete}`, { method: 'DELETE' });
if (res.ok) {
window.location.href = window.location.pathname + window.location.search;
} else {
alert("Error al eliminar.");
}
} catch (err) {
alert("Error de conexión.");
}
}
</script>
{% endblock %}

View File

@@ -1,243 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS - Inventory Management</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
padding: 20px;
background: #f4f7f6;
color: #333;
margin: 0;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
padding: 10px 25px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.main-container { display: flex; gap: 25px; }
.column { flex: 1; min-width: 400px; }
.card {
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
text-align: center;
margin-bottom: 20px;
}
#display-img {
max-width: 250px;
max-height: 250px;
border-radius: 10px;
margin-bottom: 15px;
object-fit: contain;
}
.price-tag { font-size: 3em; color: #2c3e50; font-weight: 800; margin: 10px 0; }
input {
display: block;
width: 100%;
margin: 12px 0;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
box-sizing: border-box;
}
button { padding: 10px 15px; cursor: pointer; border-radius: 6px; border: none; transition: 0.2s; }
.btn-save { background: #2c3e50; color: white; width: 100%; font-size: 1.1em; }
.btn-save:hover { background: #34495e; }
.btn-edit { background: #f1c40f; color: #000; }
.btn-del { background: #e74c3c; color: white; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; }
th, td { padding: 15px; border-bottom: 1px solid #eee; text-align: left; }
th { background: #fafafa; }
#new-product-prompt {
display: none;
background: #3498db;
color: white;
padding: 12px 20px;
border-radius: 10px;
margin-bottom: 20px;
animation: slideDown 0.4s ease;
/* This is what you were missing */
display: none; /* JS will change this to flex */
justify-content: space-between;
align-items: center;
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
</head>
<body>
<div class="header-bar">
<h2 style="margin:0;">SekiPOS v1.0</h2>
<div>
<span>Usuario: <b>{{ user.username }}</b></span> |
<a href="/logout" style="color: #e74c3c; text-decoration: none; font-weight: bold;">Cerrar Sesión</a>
</div>
</div>
<div class="main-container">
<div class="column">
<!-- Notification for New Items -->
<div id="new-product-prompt">
<span style="flex-grow: 1;">¡Nuevo! Barcode <b><span id="new-barcode-display"></span></b>. ¿Deseas agregarlo?</span>
<button onclick="dismissPrompt()" style="background:rgba(255,255,255,0.2); color:white; margin-left: 15px;">Omitir</button>
</div>
<!-- Visual Display Card -->
<div class="card" id="scan-display">
<h2 style="color: #7f8c8d; margin-top:0;">Último Escaneado</h2>
<img id="display-img" src="./static/placeholder.png" alt="Producto">
<h1 id="display-name" style="margin: 10px 0;">Escanea un producto</h1>
<div class="price-tag" id="display-price">$0</div>
<p id="display-barcode" style="color: #bdc3c7; font-family: monospace;"></p>
</div>
<!-- Form Card -->
<div class="card">
<h3 id="form-title" style="margin-top:0;">Agregar/Editar Producto</h3>
<form action="/upsert" method="POST" id="product-form">
<input type="text" name="barcode" id="form-barcode" placeholder="Barcode" required>
<input type="text" name="name" id="form-name" placeholder="Nombre del Producto" required>
<input type="number" name="price" id="form-price" placeholder="Precio (CLP)" required>
<input type="text" name="image_url" id="form-image" placeholder="URL de Imagen">
<button type="submit" class="btn-save">Guardar en Inventario</button>
</form>
</div>
</div>
<div class="column">
<div class="card">
<h3 style="text-align: left; margin-top:0;">Inventario</h3>
<input type="text" id="searchInput" onkeyup="searchTable()" placeholder="Buscar por nombre o código...">
<table id="inventoryTable">
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td style="font-family: monospace;">{{ p[0] }}</td>
<td>{{ p[1] }}</td>
<td class="price-cell" data-value="{{ p[2] }}"></td>
<td style="white-space: nowrap;">
<button class="btn-edit" onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}')">Editar</button>
<form action="/delete/{{ p[0] }}" method="POST" style="display:inline;">
<button type="submit" class="btn-del" onclick="return confirm('¿Eliminar producto?')">Borrar</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
var socket = io();
// Chilean Peso Formatter (e.g., 1500 -> $1.500)
const clpFormatter = new Intl.NumberFormat('es-CL', {
style: 'currency',
currency: 'CLP',
minimumFractionDigits: 0
});
function formatTablePrices() {
document.querySelectorAll('.price-cell').forEach(td => {
const val = parseFloat(td.getAttribute('data-value'));
td.innerText = clpFormatter.format(val);
});
}
formatTablePrices();
socket.on('new_scan', function(data) {
dismissPrompt();
document.getElementById('display-name').innerText = data.name;
document.getElementById('display-price').innerText = clpFormatter.format(data.price);
document.getElementById('display-barcode').innerText = data.barcode;
document.getElementById('display-img').src = data.image || './static/placeholder.png';
});
socket.on('scan_error', function(data) {
const prompt = document.getElementById('new-product-prompt');
document.getElementById('new-barcode-display').innerText = data.barcode;
document.getElementById('display-price').innerText = "$ ???";
prompt.style.display = 'flex';
// Update big display card with internet data (if any)
if (data.name) {
document.getElementById('display-name').innerText = data.name + " (Nuevo)";
document.getElementById('display-price').innerText = "$ ???";
document.getElementById('display-img').src = data.image || './static/placeholder.png';
} else {
document.getElementById('display-name').innerText = "Producto Desconocido";
document.getElementById('display-img').src = './static/placeholder.png';
}
document.getElementById('display-barcode').innerText = data.barcode;
// Pre-fill form
document.getElementById('form-barcode').value = data.barcode;
document.getElementById('form-name').value = data.name || '';
document.getElementById('form-image').value = data.image || '';
document.getElementById('form-price').value = '';
document.getElementById('form-title').innerText = "Crear Nuevo: " + (data.name || data.barcode);
document.getElementById('form-price').focus();
});
function dismissPrompt() {
document.getElementById('new-product-prompt').style.display = 'none';
}
function editProduct(barcode, name, price, image) {
dismissPrompt();
document.getElementById('form-barcode').value = barcode;
document.getElementById('form-name').value = name;
document.getElementById('form-price').value = price;
document.getElementById('form-image').value = image;
document.getElementById('form-title').innerText = "Editando: " + name;
window.scrollTo({top: 0, behavior: 'smooth'});
}
function searchTable() {
var input = document.getElementById("searchInput");
var filter = input.value.toUpperCase();
var tr = document.getElementById("inventoryTable").getElementsByTagName("tr");
for (var i = 1; i < tr.length; i++) {
var content = tr[i].textContent || tr[i].innerText;
tr[i].style.display = content.toUpperCase().indexOf(filter) > -1 ? "" : "none";
}
}
// Handle instant updates for deletions without full refresh
socket.on('product_deleted', function(data) {
window.location.reload();
});
</script>
</body>
</html>

787
templates/inventory.html Normal file
View File

@@ -0,0 +1,787 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, scanner_modal %}
{% block title %}Inventario{% endblock %}
{% block head %}
<script src="https://unpkg.com/html5-qrcode"></script>
<style>
.table th:last-child,
.table td:last-child {
width: 1%;
white-space: nowrap;
text-align: right;
}
.name-cell {
max-width: 200px; /* Adjust based on preference */
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 992px) {
.name-cell {
max-width: 400px;
}
}
.sort-asc::after {
content: " ↓";
font-size: 0.8rem;
}
.sort-desc::after {
content: " ↑";
font-size: 0.8rem;
}
th[onclick] {
cursor: pointer;
user-select: none;
}
th[onclick]:hover {
background-color: var(--input-bg) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="row g-3">
<!-- ── LEFT COLUMN ── -->
<div class="col-12 col-lg-5">
<!-- New product prompt -->
<div id="new-product-prompt"
class="new-product-prompt p-3 mb-3 d-none d-flex justify-content-between align-items-center">
<span>Nuevo: <b id="new-barcode-display"></b></span>
<button onclick="dismissPrompt()" class="btn btn-sm" style="background:rgba(0,0,0,0.25);color:#fff;">
Omitir
</button>
</div>
<!-- Last scanned card -->
<div class="discord-card p-3 mb-3 text-center">
<p class="mb-1 fw-semibold"
style="color:var(--text-muted); font-size:0.8rem; text-transform:uppercase; letter-spacing:.05em;">
Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h5 id="display-name" class="mb-1">Esperando scan...</h5>
<div class="price-tag" id="display-price">$0</div>
<p id="display-barcode" class="mb-0 mt-1" style="font-family:monospace; opacity:.5; font-size:.8rem;">
</p>
</div>
<button class="btn btn-accent mb-3 w-100" onclick="startScanner()">
<i class="bi bi-qr-code-scan me-2"></i>Escanear con Cámara
</button>
<!-- Edit / Create card -->
<div class="discord-card p-3">
<h6 id="form-title" class="mb-3 fw-bold">Editar / Crear</h6>
<form action="/upsert" method="POST" id="product-form">
<div class="input-group mb-2">
<input class="form-control" type="text" name="barcode" id="form-barcode" placeholder="Barcode"
required>
<button class="btn btn-outline-secondary" type="button" onclick="generateBarcode()" title="Generar código">
<i class="bi bi-upc-scan"></i>
</button>
</div>
<input class="form-control mb-2" type="text" name="name" id="form-name" placeholder="Nombre" required>
<div class="row g-2 mb-2">
<div class="col-8">
<input class="form-control" type="text" inputmode="numeric" name="price" id="form-price"
placeholder="Precio (CLP)" required
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';">
</div>
<div class="col-4">
<select class="form-select" name="unit_type" id="form-unit-type">
<option value="unit">Unidad</option>
<option value="kg">Kg</option>
</select>
</div>
</div>
<input class="form-control mb-2" type="number" step="1" name="stock" id="form-stock"
placeholder="Stock Inicial">
<div class="input-group mb-3">
<input class="form-control" type="text" name="image_url" id="form-image" placeholder="URL Imagen">
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display: none;"
onchange="handleFileUpload(this)">
<button class="btn btn-outline-secondary" type="button"
onclick="document.getElementById('camera-input').click()">
<i class="bi bi-camera"></i>
</button>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-accent flex-grow-1">
<i class="bi bi-floppy me-1"></i>Guardar
</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
<i class="bi bi-eraser"></i>
</button>
</div>
</form>
</div>
</div>
<!-- ── RIGHT COLUMN ── -->
<div class="col-12 col-lg-7">
<div class="discord-card p-3">
<!-- Bulk actions bar -->
<div id="bulk-bar" class="bulk-bar p-2 mb-3 d-flex justify-content-between align-items-center">
<span><b id="selected-count">0</b> seleccionados</span>
<div class="d-flex align-items-center gap-2">
<input type="number" id="bulk-price-input" class="form-control form-control-sm"
placeholder="Precio">
<button onclick="applyBulkPrice()" class="btn btn-sm"
style="background:#fff; color:var(--accent); font-weight:600;">OK</button>
<button onclick="applyBulkDelete()" class="btn btn-sm btn-danger-discord">
<i class="bi bi-trash"></i>
</button>
<button onclick="clearSelection()" class="btn btn-sm"
style="background: rgba(255,255,255,0.2); color:#fff;">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Search -->
<div class="position-relative mb-3">
<input type="text" id="searchInput" class="form-control pe-5" onkeyup="searchTable()"
placeholder="Filtrar productos...">
<button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-muted"
onclick="clearSearch()" id="clearSearchBtn" style="display: none; text-decoration: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Table -->
<div class="table-responsive">
<table class="table table-borderless mb-0" id="inventoryTable">
<thead>
<tr>
<th style="width:36px;"><input class="form-check-input" type="checkbox" id="select-all"
onclick="toggleAll(this)"></th>
<th class="col-barcode" onclick="sortTable(1)">Código</th>
<th onclick="sortTable(2)">Nombre</th>
<th onclick="sortTable(3)">Stock</th>
<th onclick="sortTable(4)">Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr data-barcode="{{ p[0] }}">
<td><input class="form-check-input product-checkbox" type="checkbox"
onclick="updateBulkBar()"></td>
<td class="col-barcode">{{ p[0] }}</td>
<td class="name-cell">{{ p[1] }}</td>
<td>
{% if p[5] == 'kg' %}
<span class="text-muted d-inline-block text-center" style="width: 45px;">-</span>
{% else %}
{{ p[4] | int }} <small class="text-muted">Uni</small>
{% endif %}
</td>
<td class="price-cell" data-value="{{ p[2] }}"></td>
<td class="text-nowrap">
<button class="btn btn-accent btn-sm"
onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}', '{{ p[4] }}', '{{ p[5] }}')"
data-bs-toggle="modal" data-bs-target="#editModal">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger-discord btn-sm btn-del-sm" data-bs-toggle="modal"
data-bs-target="#deleteModal" data-barcode="{{ p[0] }}" data-name="{{ p[1] }}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% call confirm_modal('editModal', 'Editar Producto', 'btn-accent', 'Editar', 'confirmEdit()') %}
<p>¿Quieres editar <strong id="editProductName"></strong>?</p>
{% endcall %}
{% call confirm_modal('deleteModal', 'Eliminar Producto', 'btn-danger-discord', 'Eliminar', 'confirmDelete()') %}
<p>¿Seguro que quieres eliminar <strong id="deleteProductName"></strong>?</p>
<p class="text-muted small">Esta acción no se puede deshacer.</p>
{% endcall %}
{% call confirm_modal('bulkConfirmModal', 'Confirmar Cambio Masivo', 'btn-accent', 'Confirmar Cambios',
'executeBulkPrice()') %}
<div class="text-center">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a actualizar <strong id="bulk-count-text">0</strong> productos al precio de <strong
id="bulk-price-text"></strong>.</p>
</div>
{% endcall %}
{% call confirm_modal('bulkDeleteModal', 'Confirmar Eliminación Masiva', 'btn-danger-discord', 'Eliminar
permanentemente', 'executeBulkDelete()') %}
<div class="text-center">
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a eliminar <strong id="bulk-delete-count">0</strong> productos permanentemente.</p>
<p class="text-muted small">Esta acción borrará los datos y las imágenes de la caché.</p>
</div>
{% endcall %}
{{ scanner_modal() }}
{% endblock %}
{% block scripts %}
<script>
/* ── Socket.IO ── */
const socket = io();
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
function formatAll() {
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
}
formatAll();
// Inside socket.on('new_scan')
socket.on('new_scan', d => {
// Update the "Last Scanned" card
document.getElementById('display-name').innerText = d.name;
document.getElementById('display-price').innerText = clp.format(d.price);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
let title = 'Editando: ' + d.name;
if (d.note) title += ` (${d.note})`;
// Update the actual form
updateForm(d.barcode, d.name, d.price, d.image, title, d.stock, d.unit_type);
});
socket.on('scan_error', d => {
const prompt = document.getElementById('new-product-prompt');
document.getElementById('new-barcode-display').innerText = d.barcode;
prompt.classList.remove('d-none');
// Update the "Last Scanned" card so it doesn't show old data
document.getElementById('display-name').innerText = d.name || "Producto Nuevo";
document.getElementById('display-price').innerText = clp.format(0);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
// Clear the price and set the name in the form
updateForm(d.barcode, d.name || '', '', d.image || '', 'Crear: ' + d.barcode);
});
socket.on('scale_update', function (data) {
console.log("Current Weight:", data.grams + "g");
// If the unit type is 'kg', update the stock field automatically
const unitType = document.getElementById('form-unit-type').value;
if (unitType === 'kg') {
document.getElementById('form-stock').value = data.kilograms;
}
});
socket.on('scale_update', function (data) {
const unitType = document.getElementById('form-unit-type').value;
if (unitType === 'kg') {
document.getElementById('form-stock').value = data.kilograms;
}
});
// Replace your existing updateForm function with this one
function updateForm(b, n, p, i, t, stock, unit) {
dismissPrompt();
document.getElementById('form-barcode').value = b;
document.getElementById('form-name').value = n;
// Force integers here to nuke the decimals once and for all
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
document.getElementById('form-unit-type').value = unit || 'unit';
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
// Add a timestamp to the URL if it's a local cache image
let displayImg = i || './static/placeholder.png';
if (displayImg.includes('/static/cache/')) {
displayImg += (displayImg.includes('?') ? '&' : '?') + 't=' + Date.now();
}
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
document.getElementById('display-img').src = displayImg;
document.getElementById('display-name').innerText = n || 'Producto Nuevo';
document.getElementById('form-price').value = p ? parseInt(p, 10).toLocaleString('es-CL') : '';
document.getElementById('display-barcode').innerText = b;
toggleStockInput(); // Show/hide stock input based on unit type
}
function dismissPrompt() {
document.getElementById('new-product-prompt').classList.add('d-none');
}
function editProduct(b, n, p, i, stock, unit) {
document.getElementById('editProductName').innerText = n;
const modal = document.getElementById('editModal');
modal.dataset.barcode = b;
modal.dataset.name = n;
modal.dataset.price = p;
modal.dataset.image = i;
modal.dataset.stock = stock;
modal.dataset.unit = unit;
}
function confirmEdit() {
const m = document.getElementById('editModal');
updateForm(m.dataset.barcode, m.dataset.name, m.dataset.price, m.dataset.image, 'Editando: ' + m.dataset.name, m.dataset.stock, m.dataset.unit);
window.scrollTo({ top: 0, behavior: 'smooth' });
bootstrap.Modal.getInstance(m).hide();
}
function confirmDelete() {
const modal = document.getElementById('deleteModal');
const form = document.createElement('form');
form.action = `/delete/${modal.dataset.barcode}`;
form.method = 'POST';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
}
// Delete modal setup
document.getElementById('deleteModal').addEventListener('show.bs.modal', e => {
const button = e.relatedTarget;
document.getElementById('deleteProductName').innerText = button.dataset.name;
document.getElementById('deleteModal').dataset.barcode = button.dataset.barcode;
});
/* ── Bulk selection ── */
function toggleAll(src) {
const visibleRows = document.querySelectorAll('#inventoryTable tbody tr:not([style*="display: none"])');
visibleRows.forEach(row => {
const cb = row.querySelector('.product-checkbox');
if (cb) cb.checked = src.checked;
});
updateBulkBar();
}
function updateBulkBar() {
document.getElementById('selected-count').innerText =
document.querySelectorAll('.product-checkbox:checked').length;
}
function clearForm() {
document.getElementById('product-form').reset();
document.getElementById('form-price').value = '';
document.getElementById('form-price').dataset.raw = '';
document.getElementById('form-title').innerText = 'Editar / Crear';
// Reset preview card
document.getElementById('display-img').src = './static/placeholder.png';
document.getElementById('display-name').innerText = 'Esperando scan...';
document.getElementById('display-price').innerText = '$0';
document.getElementById('display-barcode').innerText = '';
toggleStockInput(); // Show/hide stock input based on default unit type
}
async function handleFileUpload(input) {
const barcode = document.getElementById('form-barcode').value;
if (!barcode) {
alert("Primero escanea o ingresa un código de barras.");
input.value = '';
return;
}
const file = input.files[0];
if (!file) return;
// Show a "loading" state if you feel like being fancy
const originalBtnContent = document.querySelector('button[onclick*="camera-input"]').innerHTML;
document.querySelector('button[onclick*="camera-input"]').innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const compressedBlob = await compressImage(file, 800, 0.7); // Max 800px, 70% quality
const formData = new FormData();
formData.append('image', compressedBlob, `photo_${barcode}.jpg`);
formData.append('barcode', barcode);
const res = await fetch('/upload_image', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
document.getElementById('form-image').value = data.image_url;
document.getElementById('display-img').src = data.image_url;
} else {
alert("Error al subir imagen: " + data.error);
}
} catch (err) {
console.error(err);
alert("Error procesando imagen.");
} finally {
document.querySelector('button[onclick*="camera-input"]').innerHTML = originalBtnContent;
}
}
// The compression engine
function compressImage(file, maxWidth, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => {
resolve(blob);
}, 'image/jpeg', quality);
};
img.onerror = err => reject(err);
};
reader.onerror = err => reject(err);
});
}
function applyBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
if (!price || checked.length === 0) return;
// Set text in the pretty modal
document.getElementById('bulk-count-text').innerText = checked.length;
document.getElementById('bulk-price-text').innerText = clp.format(price);
// Show the modal
const bulkModal = new bootstrap.Modal(document.getElementById('bulkConfirmModal'));
bulkModal.show();
}
async function executeBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_price_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes, new_price: price })
});
if (res.ok) {
checked.forEach(cb => {
const cell = cb.closest('tr').querySelector('.price-cell');
cell.setAttribute('data-value', price);
cell.innerText = clp.format(price);
cb.checked = false;
});
document.getElementById('bulk-price-input').value = '';
updateBulkBar();
// Hide modal
const modalEl = document.getElementById('bulkConfirmModal');
bootstrap.Modal.getInstance(modalEl).hide();
}
}
function applyBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
if (checked.length === 0) return;
document.getElementById('bulk-delete-count').innerText = checked.length;
const delModal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
delModal.show();
}
async function executeBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes })
});
if (res.ok) {
checked.forEach(cb => {
cb.closest('tr').remove(); // Remove the row from the table immediately
});
updateBulkBar();
const modalEl = document.getElementById('bulkDeleteModal');
bootstrap.Modal.getInstance(modalEl).hide();
} else {
alert("Error al eliminar productos.");
}
}
/* ── Search ── */
function searchTable() {
const input = document.getElementById('searchInput');
const q = input.value.toUpperCase();
const clearBtn = document.getElementById('clearSearchBtn');
// Show/hide clear button based on input
clearBtn.style.display = q.length > 0 ? 'block' : 'none';
document.querySelectorAll('#inventoryTable tbody tr').forEach(tr => {
tr.style.display = tr.innerText.toUpperCase().includes(q) ? '' : 'none';
});
document.getElementById('select-all').checked = false;
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
searchTable();
input.focus();
}
let sortDirections = [true, true, true, true, true];
function sortTable(colIdx) {
const table = document.getElementById("inventoryTable");
const tbody = table.querySelector("tbody");
const ths = table.querySelectorAll("thead th");
const rows = Array.from(tbody.querySelectorAll("tr"));
const isAscending = sortDirections[colIdx];
const sortedRows = rows.sort((a, b) => {
let valA = a.cells[colIdx].innerText.trim();
let valB = b.cells[colIdx].innerText.trim();
// Specific logic for numeric columns
if (colIdx === 3 || colIdx === 4) {
// If it's Stock (3) or Price (4), strip everything but numbers
// This handles the "Uni" text and the currency symbols
valA = parseFloat(valA.replace(/[^\d.-]/g, '')) || 0;
valB = parseFloat(valB.replace(/[^\d.-]/g, '')) || 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return isAscending ? -1 : 1;
if (valA > valB) return isAscending ? 1 : -1;
return 0;
});
ths.forEach(th => th.classList.remove('sort-asc', 'sort-desc'));
ths[colIdx].classList.add(isAscending ? 'sort-asc' : 'sort-desc');
sortDirections[colIdx] = !isAscending;
sortedRows.forEach(row => tbody.appendChild(row));
document.getElementById('select-all').checked = false;
}
let html5QrCode;
let currentCameraId;
async function startScanner() {
const modal = new bootstrap.Modal(document.getElementById('scannerModal'));
modal.show();
if (!html5QrCode) {
html5QrCode = new Html5Qrcode("reader");
}
try {
const devices = await Html5Qrcode.getCameras();
const select = document.getElementById('camera-select');
select.innerHTML = '';
if (devices && devices.length) {
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.id;
option.dataset.index = index;
option.text = device.label || `Cámara ${index + 1}`;
select.appendChild(option);
});
const savedIndex = getCookie('cameraIndex');
const targetIndex = (savedIndex !== null && savedIndex < devices.length) ? savedIndex : devices.length - 1;
currentCameraId = devices[targetIndex].id;
select.value = currentCameraId;
launchCamera(currentCameraId);
} else {
alert("No se encontraron cámaras.");
}
} catch (err) {
console.error("Error obteniendo cámaras:", err);
alert("Error de permisos de cámara. Revisa la conexión HTTPS.");
}
}
let torchEnabled = false;
function isTorchSupported() {
if (!html5QrCode || !html5QrCode.isScanning) return false;
const settings = html5QrCode.getRunningTrackSettings();
return "torch" in settings;
}
async function toggleTorch() {
if (!isTorchSupported()) return;
torchEnabled = !torchEnabled;
try {
await html5QrCode.applyVideoConstraints({
advanced: [{ torch: torchEnabled }]
});
const btn = document.getElementById('torch-btn');
if (torchEnabled) {
btn.classList.replace('btn-outline-secondary', 'btn-accent');
btn.innerHTML = '<i class="bi bi-lightbulb-fill"></i>';
} else {
btn.classList.replace('btn-accent', 'btn-outline-secondary');
btn.innerHTML = '<i class="bi bi-lightbulb"></i>';
}
} catch (err) {
console.error("Torch error:", err);
}
}
async function launchCamera(cameraId) {
const config = { fps: 10, qrbox: { width: 250, height: 150 } };
if (html5QrCode.isScanning) {
await html5QrCode.stop();
}
try {
await html5QrCode.start(
cameraId,
config,
(decodedText) => {
stopScanner();
bootstrap.Modal.getInstance(document.getElementById('scannerModal')).hide();
fetch(`/scan?content=${decodedText}`)
.then(res => {
if (res.status === 404) console.log("Nuevo producto detectado");
});
}
);
setTimeout(() => {
html5QrCode.applyVideoConstraints({
focusMode: "continuous",
advanced: [{ zoom: 2.0 }],
});
const torchBtn = document.getElementById('torch-btn');
if (isTorchSupported()) {
torchBtn.style.setProperty('display', 'block', 'important'); // Use !important to override inline styles
torchEnabled = false;
torchBtn.classList.replace('btn-accent', 'btn-outline-secondary');
torchBtn.innerHTML = '<i class="bi bi-lightbulb"></i>';
} else {
torchBtn.style.display = 'none';
console.log("Torch not supported on this device/browser.");
}
}, 250);
} catch (err) {
console.error("Camera start error:", err);
}
}
function stopScanner() {
if (html5QrCode && html5QrCode.isScanning) {
document.getElementById('torch-btn').style.display = 'none';
html5QrCode.stop().then(() => {
html5QrCode.clear();
}).catch(err => console.error("Stop error", err));
}
}
function switchCamera(cameraId) {
if (cameraId) {
const select = document.getElementById('camera-select');
const selectedOption = select.options[select.selectedIndex];
if (selectedOption && selectedOption.dataset.index !== undefined) {
setCookie('cameraIndex', selectedOption.dataset.index, 365);
}
currentCameraId = cameraId;
launchCamera(cameraId);
}
}
function generateBarcode() {
const existing = new Set(
Array.from(document.querySelectorAll('#inventoryTable tbody tr'))
.map(tr => tr.getAttribute('data-barcode'))
);
const prefix = '78';
let code;
do {
code = prefix;
for (let i = 0; i < 10; i++) {
code += Math.floor(Math.random() * 10);
}
} while (existing.has(code));
document.getElementById('form-barcode').value = code;
}
function toggleStockInput() {
const unitSelect = document.getElementById('form-unit-type');
const stockInput = document.getElementById('form-stock');
if (unitSelect.value === 'kg') {
stockInput.classList.add('d-none');
stockInput.disabled = true;
stockInput.value = '';
} else {
stockInput.classList.remove('d-none');
stockInput.disabled = false;
}
}
document.getElementById('form-unit-type').addEventListener('change', toggleStockInput);
document.getElementById('product-form').addEventListener('submit', function() {
const priceInput = document.getElementById('form-price');
priceInput.value = priceInput.value.replace(/\./g, '');
});
</script>
{% endblock %}

View File

@@ -1,25 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<title>SekiPOS Login</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #2c3e50; }
.login-box { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); text-align: center; }
input { display: block; width: 250px; margin: 10px auto; padding: 12px; border: 1px solid #ccc; border-radius: 6px; }
button { background: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 1.1em; }
</style>
</head>
<body>
<div class="login-box">
<h2>SekiPOS Access</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}<p style="color: red;">{{ messages[0] }}</p>{% endif %}
{% endwith %}
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Unlock</button>
</form>
</div>
</body>
</html>
{% extends "macros/base.html" %}
{% block title %}Login{% endblock %}
{% block head %}
<style>
.login-box {
position: relative;
z-index: 10;
}
</style>
{% endblock %}
{% block content %}
<div id="tsparticles"></div>
<div class="login-box text-center">
<h2 class="fw-bold mb-1">SekiPOS</h2>
<p class="mb-4 text-muted">¡Hola de nuevo!</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="error-alert p-2 mb-3">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="POST">
<input class="form-control mb-3" type="text" name="username" placeholder="Usuario" required autofocus>
<input class="form-control mb-3" type="password" name="password" placeholder="Contraseña" required>
<button type="submit" class="btn btn-login w-100">
Iniciar Sesión
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/tsparticles@3.3.0/tsparticles.bundle.min.js"></script>
<script>
(async () => {
await tsParticles.load({
id: "tsparticles",
options: {
fullScreen: {
enable: true,
zIndex: -1
},
background: {
color: {
value: "transparent",
},
},
fpsLimit: 60,
interactivity: {
events: {
onHover: {
enable: true,
mode: "grab",
},
},
modes: {
grab: {
distance: 150,
links: {
opacity: 0.8
}
}
}
},
particles: {
color: {
value: "#9b59b6",
},
links: {
color: "#9b59b6",
distance: 150,
enable: true,
opacity: 0.6,
width: 1.5,
},
move: {
enable: true,
speed: 1,
},
number: {
density: {
enable: true,
width: 1920,
height: 1080
},
value: 80,
},
opacity: {
value: 0.9,
},
size: {
value: { min: 1, max: 3 },
},
},
},
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS - {% block title %}{% endblock %}</title>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/x-icon">
<script>
// Apply theme BEFORE any CSS loads to prevent flash
(function() {
var theme = localStorage.getItem('theme');
var isDark = (theme === 'dark') || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
if (localStorage.getItem('seki_food_mode') === 'true') {
document.body.classList.add('food-mode-active');
}
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% include 'macros/navbar.html' %}
<main class="container-fluid px-3">
{% block content %}{% endblock %}
</main>
{% from 'macros/modals.html' import settings_modal %}
{{ settings_modal() }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More