Создано приложение POS с планшетным интерфейсом терминала продаж
This commit is contained in:
@@ -74,6 +74,7 @@ TENANT_APPS = [
|
|||||||
'products', # Товары и категории
|
'products', # Товары и категории
|
||||||
'orders', # Заказы
|
'orders', # Заказы
|
||||||
'inventory', # Складской учет
|
'inventory', # Складской учет
|
||||||
|
'pos', # POS Terminal
|
||||||
]
|
]
|
||||||
|
|
||||||
# Объединяем для INSTALLED_APPS
|
# Объединяем для INSTALLED_APPS
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
0
myproject/pos/__init__.py
Normal file
0
myproject/pos/__init__.py
Normal file
3
myproject/pos/admin.py
Normal file
3
myproject/pos/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# POS admin (placeholder)
|
||||||
7
myproject/pos/apps.py
Normal file
7
myproject/pos/apps.py
Normal 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'
|
||||||
0
myproject/pos/migrations/__init__.py
Normal file
0
myproject/pos/migrations/__init__.py
Normal file
3
myproject/pos/models.py
Normal file
3
myproject/pos/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# POS app models (placeholder for future extensions)
|
||||||
53
myproject/pos/static/pos/css/terminal.css
Normal file
53
myproject/pos/static/pos/css/terminal.css
Normal 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;
|
||||||
|
}
|
||||||
170
myproject/pos/static/pos/js/terminal.js
Normal file
170
myproject/pos/static/pos/js/terminal.js
Normal 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();
|
||||||
122
myproject/pos/templates/pos/terminal.html
Normal file
122
myproject/pos/templates/pos/terminal.html
Normal 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
9
myproject/pos/urls.py
Normal 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
30
myproject/pos/views.py
Normal 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)
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user