Создано приложение POS с планшетным интерфейсом терминала продаж

This commit is contained in:
2025-11-16 13:38:28 +03:00
parent a073b1aa77
commit 139ac431ee
14 changed files with 406 additions and 3 deletions

View File

@@ -74,6 +74,7 @@ TENANT_APPS = [
'products', # Товары и категории 'products', # Товары и категории
'orders', # Заказы 'orders', # Заказы
'inventory', # Складской учет 'inventory', # Складской учет
'pos', # POS Terminal
] ]
# Объединяем для INSTALLED_APPS # Объединяем для INSTALLED_APPS

View File

@@ -21,6 +21,7 @@ urlpatterns = [
path('customers/', include('customers.urls')), # Управление клиентами path('customers/', include('customers.urls')), # Управление клиентами
path('inventory/', include('inventory.urls')), # Управление складом path('inventory/', include('inventory.urls')), # Управление складом
path('orders/', include('orders.urls')), # Управление заказами path('orders/', include('orders.urls')), # Управление заказами
path('pos/', include('pos.urls')), # POS Terminal
] ]
# Serve media files during development # Serve media files during development

View File

@@ -495,10 +495,14 @@ class Order(models.Model):
return f"Заказ #{self.order_number} - {self.customer}" return f"Заказ #{self.order_number} - {self.customer}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Генерируем уникальный номер заказа при создании # Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
if not self.order_number: if not self.order_number:
last_order = Order.objects.order_by('-order_number').first() last_order = Order.objects.order_by('-order_number').first()
self.order_number = (last_order.order_number if last_order else 0) + 1 if last_order:
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
self.order_number = max(last_order.order_number + 1, 100)
else:
self.order_number = 100
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self): def clean(self):

View File

3
myproject/pos/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# POS admin (placeholder)

7
myproject/pos/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PosConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pos'
verbose_name = 'POS Terminal'

View File

3
myproject/pos/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# POS app models (placeholder for future extensions)

View File

@@ -0,0 +1,53 @@
/* POS Terminal Styles */
body {
background-color: #e9ecef;
}
.pos-container {
max-width: 100%;
padding: 0 1rem;
}
.product-card {
cursor: pointer;
user-select: none;
transition: all 0.2s;
border-radius: 12px;
border: 1px solid #dee2e6;
background: white;
height: 100%;
min-height: 140px;
}
.product-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.product-card.selected {
background: #e7f3ff;
border-color: #0d6efd;
}
.product-card .card-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.product-name {
font-weight: 500;
font-size: 1rem;
margin-bottom: 0.5rem;
color: #495057;
}
.product-stock {
font-size: 0.9rem;
color: #6c757d;
font-style: italic;
}

View File

@@ -0,0 +1,170 @@
// POS Terminal JavaScript
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
const PRODUCTS = JSON.parse(document.getElementById('productsData').textContent);
let currentCategoryId = null;
const cart = new Map(); // productId -> {id, name, price, qty}
function formatMoney(v) {
return (Number(v)).toFixed(2);
}
function renderProducts() {
const grid = document.getElementById('productGrid');
grid.innerHTML = '';
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
let filtered = currentCategoryId
? PRODUCTS.filter(p => (p.category_ids || []).includes(currentCategoryId))
: PRODUCTS;
if (searchTerm) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(searchTerm));
}
filtered.forEach(p => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
const card = document.createElement('div');
card.className = 'card product-card';
card.onclick = () => addToCart(p);
const body = document.createElement('div');
body.className = 'card-body';
const name = document.createElement('div');
name.className = 'product-name';
name.textContent = p.name;
const stock = document.createElement('div');
stock.className = 'product-stock';
stock.textContent = 'В наличии';
body.appendChild(name);
body.appendChild(stock);
card.appendChild(body);
col.appendChild(card);
grid.appendChild(col);
});
}
function addToCart(p) {
if (!cart.has(p.id)) {
cart.set(p.id, { id: p.id, name: p.name, price: Number(p.price), qty: 1 });
} else {
cart.get(p.id).qty += 1;
}
renderCart();
}
function updateQty(id, delta) {
if (!cart.has(id)) return;
const item = cart.get(id);
item.qty += delta;
if (item.qty <= 0) cart.delete(id);
renderCart();
}
function renderCart() {
const list = document.getElementById('cartList');
list.innerHTML = '';
let total = 0;
if (cart.size === 0) {
list.innerHTML = '<p class="text-muted text-center py-4 small">Корзина пуста</p>';
document.getElementById('cartTotal').textContent = '0.00';
return;
}
cart.forEach(item => {
const row = document.createElement('div');
row.className = 'mb-2 pb-2 border-bottom';
const nameRow = document.createElement('div');
nameRow.className = 'd-flex justify-content-between align-items-start mb-1';
nameRow.innerHTML = `
<div class="fw-semibold small">${item.name}</div>
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="event.stopPropagation(); removeFromCart(${item.id});">
<i class="bi bi-x"></i>
</button>
`;
const controlsRow = document.createElement('div');
controlsRow.className = 'd-flex justify-content-between align-items-center';
const controls = document.createElement('div');
controls.className = 'btn-group btn-group-sm';
const minus = document.createElement('button');
minus.className = 'btn btn-outline-secondary';
minus.innerHTML = '<i class="bi bi-dash"></i>';
minus.onclick = (e) => { e.stopPropagation(); updateQty(item.id, -1); };
const qtySpan = document.createElement('button');
qtySpan.className = 'btn btn-outline-secondary disabled';
qtySpan.textContent = item.qty;
const plus = document.createElement('button');
plus.className = 'btn btn-outline-secondary';
plus.innerHTML = '<i class="bi bi-plus"></i>';
plus.onclick = (e) => { e.stopPropagation(); updateQty(item.id, +1); };
controls.appendChild(minus);
controls.appendChild(qtySpan);
controls.appendChild(plus);
const priceDiv = document.createElement('div');
priceDiv.className = 'text-end small';
priceDiv.innerHTML = `<strong>${formatMoney(item.price * item.qty)}</strong>`;
controlsRow.appendChild(controls);
controlsRow.appendChild(priceDiv);
row.appendChild(nameRow);
row.appendChild(controlsRow);
list.appendChild(row);
total += item.qty * item.price;
});
document.getElementById('cartTotal').textContent = formatMoney(total);
}
function removeFromCart(id) {
cart.delete(id);
renderCart();
}
function clearCart() {
cart.clear();
renderCart();
}
document.getElementById('clearCart').onclick = clearCart;
// Заглушки для функционала (будет реализовано позже)
document.getElementById('checkoutNow').onclick = async () => {
alert('Функционал будет подключен позже: создание заказа и списание со склада.');
};
document.getElementById('scheduleLater').onclick = async () => {
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
};
// Categories removed from this view - can be added as filter dropdown later if needed
// Search functionality
document.getElementById('searchInput').addEventListener('input', () => {
renderProducts();
});
// Customer selection
document.getElementById('customerSelectBtn').addEventListener('click', () => {
alert('Функция выбора клиента будет реализована позже');
});
// Инициализация
renderProducts();
renderCart();

View File

@@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}POS Terminal{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'pos/css/terminal.css' %}">
{% endblock %}
{% block content %}
<!-- Main Content -->
<div class="pos-container">
<div class="row g-3">
<!-- Products Grid (Left side - 8/12) -->
<div class="col-md-8">
<!-- Search Box -->
<div class="mb-3">
<input type="text" class="form-control" id="searchInput" placeholder="Поиск по товарам...">
</div>
<div class="row g-3" id="productGrid"></div>
</div>
<!-- Right Panel (4/12) -->
<div class="col-md-4">
<!-- Cart Panel -->
<div class="card mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">Корзина</h6>
<button class="btn btn-outline-primary btn-sm" id="customerSelectBtn">
<i class="bi bi-person"></i> Выбрать клиента
</button>
</div>
<div class="card-body d-flex flex-column" style="min-height: 300px;">
<div id="cartList" class="flex-grow-1 mb-3" style="overflow-y: auto; max-height: 300px;"></div>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-top pt-3">
<h5 class="mb-0">Итого:</h5>
<h5 class="mb-0 text-primary" id="cartTotal">0.00</h5>
</div>
<div class="mb-3">
<label class="form-label small">Способ оплаты</label>
<select class="form-select form-select-sm" id="paymentMethod">
<option value="cash_to_courier">Наличные</option>
<option value="card_to_courier">Карта</option>
<option value="online">Онлайн</option>
<option value="bank_transfer">Банк. перевод</option>
</select>
</div>
<div class="d-grid gap-2">
<button class="btn btn-success" id="checkoutNow">
<i class="bi bi-check2-circle"></i> Продать
</button>
<button class="btn btn-outline-primary btn-sm" id="scheduleLater">
<i class="bi bi-calendar2"></i> Запланировать
</button>
<button class="btn btn-outline-secondary btn-sm" id="clearCart">
<i class="bi bi-trash"></i> Очистить
</button>
</div>
</div>
</div>
</div>
<!-- Action Buttons Panel -->
<div class="card">
<div class="card-body p-2">
<div class="row g-2">
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
<div class="col-4">
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Hidden data containers for JavaScript -->
<script id="categoriesData" type="application/json">{{ categories_json|safe }}</script>
<script id="productsData" type="application/json">{{ products_json|safe }}</script>
<script src="{% static 'pos/js/terminal.js' %}"></script>
{% endblock %}

9
myproject/pos/urls.py Normal file
View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from django.urls import path
from . import views
app_name = 'pos'
urlpatterns = [
path('', views.pos_terminal, name='terminal'),
]

30
myproject/pos/views.py Normal file
View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from products.models import Product, ProductCategory
import json
@login_required
def pos_terminal(request):
"""
Tablet-friendly POS screen prototype.
Shows categories and in-stock products for quick tap-to-add.
"""
categories_qs = ProductCategory.objects.filter(is_active=True)
products_qs = Product.objects.filter(in_stock=True).prefetch_related('categories')
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
products = [{
'id': p.id,
'name': p.name,
'price': str(p.actual_price),
'category_ids': [c.id for c in p.categories.all()]
} for p in products_qs]
context = {
'categories_json': json.dumps(categories),
'products_json': json.dumps(products),
'title': 'POS Terminal',
}
return render(request, 'pos/terminal.html', context)

View File

@@ -39,7 +39,7 @@
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">Клиенты</a> <a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">Клиенты</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a> <a class="nav-link {% if request.resolver_match.namespace == 'pos' %}active{% endif %}" href="{% url 'pos:terminal' %}">Касса</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="{% url 'inventory:inventory-home' %}">Склад</a> <a class="nav-link {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="{% url 'inventory:inventory-home' %}">Склад</a>