Add admin order management with full status workflow.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
|
Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
|
||||||
|
|
||||||
**Текущая версия:** `v0.30-beta`
|
**Текущая версия:** `v1.0`
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| Корзина | Добавление, изменение количества, расчёт со скидкой |
|
| Корзина | Добавление, изменение количества, расчёт со скидкой |
|
||||||
| Заказы | Оформление гостем или зарегистрированным пользователем |
|
| Заказы | Оформление гостем или зарегистрированным пользователем |
|
||||||
| Личный кабинет | Профиль, история заказов со статус-таймлайном |
|
| Личный кабинет | Профиль, история заказов со статус-таймлайном |
|
||||||
| Админка | Товары, категории, скриншоты, редактор Quill, статистика |
|
| Админка | Товары, категории, заказы, скриншоты, редактор Quill, статистика |
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ bash scripts/redeploy.sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost/health
|
curl http://localhost/health
|
||||||
# {"status":"ok","version":"v0.30-beta"}
|
# {"status":"ok","version":"v1.0"}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Маршруты магазина
|
## Маршруты магазина
|
||||||
@@ -59,6 +59,7 @@ curl http://localhost/health
|
|||||||
| `/register`, `/login` | Регистрация и вход |
|
| `/register`, `/login` | Регистрация и вход |
|
||||||
| `/account` | Личный кабинет |
|
| `/account` | Личный кабинет |
|
||||||
| `/admin/` | Админ-панель |
|
| `/admin/` | Админ-панель |
|
||||||
|
| `/admin/orders` | Управление заказами и статусами |
|
||||||
|
|
||||||
## Скидки
|
## Скидки
|
||||||
|
|
||||||
@@ -73,14 +74,17 @@ curl http://localhost/health
|
|||||||
|
|
||||||
| Код | Отображение |
|
| Код | Отображение |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `new` | Новый |
|
| `pending` | В ожидании |
|
||||||
| `confirmed` | Подтверждён |
|
| `unpaid` | Не оплачено |
|
||||||
|
| `paid` | Оплачено |
|
||||||
| `processing` | В обработке |
|
| `processing` | В обработке |
|
||||||
| `shipped` | Отправлен |
|
| `shipped` | Отправлен |
|
||||||
| `delivered` | Доставлен |
|
| `delivered` | Доставлен |
|
||||||
| `cancelled` | Отменён |
|
| `cancelled` | Отменён |
|
||||||
|
| `refunded` | Возврат |
|
||||||
|
|
||||||
В личном кабинете — цветной бейдж и таймлайн прогресса.
|
В личном кабинете — цветной бейдж и таймлайн прогресса.
|
||||||
|
В админке → **Заказы** — смена статуса из выпадающего списка.
|
||||||
|
|
||||||
## Админ
|
## Админ
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -66,7 +66,7 @@ func main() {
|
|||||||
productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
|
productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
|
||||||
userSession, cartSession, tmpl,
|
userSession, cartSession, tmpl,
|
||||||
)
|
)
|
||||||
admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, statsRepo, storage, adminSession, tmpl)
|
admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, statsRepo, storage, adminSession, tmpl)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
|
||||||
@@ -103,6 +103,8 @@ func main() {
|
|||||||
mux.HandleFunc("GET /admin/categories", admin.CategoryList)
|
mux.HandleFunc("GET /admin/categories", admin.CategoryList)
|
||||||
mux.HandleFunc("POST /admin/categories", admin.CategoryCreate)
|
mux.HandleFunc("POST /admin/categories", admin.CategoryCreate)
|
||||||
mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete)
|
mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete)
|
||||||
|
mux.HandleFunc("GET /admin/orders", admin.OrderList)
|
||||||
|
mux.HandleFunc("POST /admin/orders/{id}/status", admin.OrderUpdateStatus)
|
||||||
|
|
||||||
addr := ":" + getEnv("APP_PORT", "8080")
|
addr := ":" + getEnv("APP_PORT", "8080")
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type AdminHandler struct {
|
|||||||
repo *repository.AdminRepository
|
repo *repository.AdminRepository
|
||||||
products *repository.ProductRepository
|
products *repository.ProductRepository
|
||||||
categories *repository.CategoryRepository
|
categories *repository.CategoryRepository
|
||||||
|
orders *repository.OrderRepository
|
||||||
stats *repository.StatsRepository
|
stats *repository.StatsRepository
|
||||||
storage *upload.Storage
|
storage *upload.Storage
|
||||||
session *auth.Session
|
session *auth.Session
|
||||||
@@ -71,6 +72,7 @@ func NewAdminHandler(
|
|||||||
adminRepo *repository.AdminRepository,
|
adminRepo *repository.AdminRepository,
|
||||||
productRepo *repository.ProductRepository,
|
productRepo *repository.ProductRepository,
|
||||||
categoryRepo *repository.CategoryRepository,
|
categoryRepo *repository.CategoryRepository,
|
||||||
|
orderRepo *repository.OrderRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
statsRepo *repository.StatsRepository,
|
||||||
storage *upload.Storage,
|
storage *upload.Storage,
|
||||||
session *auth.Session,
|
session *auth.Session,
|
||||||
@@ -80,6 +82,7 @@ func NewAdminHandler(
|
|||||||
repo: adminRepo,
|
repo: adminRepo,
|
||||||
products: productRepo,
|
products: productRepo,
|
||||||
categories: categoryRepo,
|
categories: categoryRepo,
|
||||||
|
orders: orderRepo,
|
||||||
stats: statsRepo,
|
stats: statsRepo,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
session: session,
|
session: session,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"shop/internal/models"
|
||||||
|
"shop/internal/orderstatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminOrdersData struct {
|
||||||
|
Title string
|
||||||
|
Email string
|
||||||
|
Orders []models.Order
|
||||||
|
StatusList []orderStatusOption
|
||||||
|
Success string
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderStatusOption struct {
|
||||||
|
Code string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusOptions() []orderStatusOption {
|
||||||
|
var list []orderStatusOption
|
||||||
|
for _, code := range orderstatus.AllCodes {
|
||||||
|
list = append(list, orderStatusOption{Code: code, Label: orderstatus.Label(code)})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) OrderList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email, ok := h.requireAuth(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, err := h.orders.All(r.Context(), 200)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("admin orders: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
success := r.URL.Query().Get("ok")
|
||||||
|
msg := ""
|
||||||
|
if success == "1" {
|
||||||
|
msg = "Статус заказа обновлён"
|
||||||
|
}
|
||||||
|
|
||||||
|
h.render(w, "admin_orders.html", adminOrdersData{
|
||||||
|
Title: "Заказы",
|
||||||
|
Email: email,
|
||||||
|
Orders: orders,
|
||||||
|
StatusList: statusOptions(),
|
||||||
|
Success: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) OrderUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireAuth(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := r.FormValue("status")
|
||||||
|
if !orderstatus.Valid(status) {
|
||||||
|
http.Redirect(w, r, "/admin/orders", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.orders.UpdateStatus(r.Context(), id, status); err != nil {
|
||||||
|
log.Printf("order status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/orders?ok=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ func ParseTemplates() (*template.Template, error) {
|
|||||||
"stepCurrent": func(status, step string) bool {
|
"stepCurrent": func(status, step string) bool {
|
||||||
return orderstatus.Step(status) == orderstatus.Step(step)
|
return orderstatus.Step(status) == orderstatus.Step(step)
|
||||||
},
|
},
|
||||||
|
"orderTerminal": orderstatus.IsTerminal,
|
||||||
|
"orderNorm": orderstatus.Normalize,
|
||||||
"catSelected": func(catID int, productCatID *int) bool {
|
"catSelected": func(catID int, productCatID *int) bool {
|
||||||
return productCatID != nil && *productCatID == catID
|
return productCatID != nil && *productCatID == catID
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -188,6 +188,55 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-alert--ok {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table__muted {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table--orders td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-order-details td {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-order-items {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form__input--sm {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-legend {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-legend__grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-hint code {
|
.admin-hint code {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -391,12 +391,16 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status--new { background: #dbeafe; color: #1d4ed8; }
|
.status--pending { background: #fef3c7; color: #b45309; }
|
||||||
.status--confirmed { background: #e0e7ff; color: #4338ca; }
|
.status--unpaid { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.status--paid { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.status--new { background: #fef3c7; color: #b45309; }
|
||||||
|
.status--confirmed { background: #dbeafe; color: #1d4ed8; }
|
||||||
.status--processing { background: #ede9fe; color: #6d28d9; }
|
.status--processing { background: #ede9fe; color: #6d28d9; }
|
||||||
.status--shipped { background: #ffedd5; color: #c2410c; }
|
.status--shipped { background: #ffedd5; color: #c2410c; }
|
||||||
.status--delivered { background: #d1fae5; color: #065f46; }
|
.status--delivered { background: #d1fae5; color: #065f46; }
|
||||||
.status--cancelled { background: #f3f4f6; color: #6b7280; }
|
.status--cancelled { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.status--refunded { background: #fce7f3; color: #be185d; }
|
||||||
|
|
||||||
.order-timeline {
|
.order-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<a href="/admin/" class="admin-nav__link">Главная</a>
|
<a href="/admin/" class="admin-nav__link">Главная</a>
|
||||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||||
<a href="/admin/categories" class="admin-nav__link">Категории</a>
|
<a href="/admin/categories" class="admin-nav__link">Категории</a>
|
||||||
|
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||||
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
|
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-header__user">
|
<div class="admin-header__user">
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{define "admin_orders.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body class="admin-body">
|
||||||
|
{{template "admin_nav" .}}
|
||||||
|
|
||||||
|
<main class="admin-main container">
|
||||||
|
<h1 class="admin-page-title">Заказы</h1>
|
||||||
|
|
||||||
|
{{if .Success}}
|
||||||
|
<div class="admin-alert admin-alert--ok">{{.Success}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<table class="admin-table admin-table--orders">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Клиент</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Изменить</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Orders}}
|
||||||
|
{{$orderStatus := .Status}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.ID}}</strong></td>
|
||||||
|
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{.GuestName}}</div>
|
||||||
|
<div class="admin-table__muted">{{.GuestEmail}}</div>
|
||||||
|
<div class="admin-table__muted">{{.GuestPhone}}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{printf "%.0f" .Total}} ₽</td>
|
||||||
|
<td>
|
||||||
|
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/admin/orders/{{.ID}}/status" class="admin-status-form">
|
||||||
|
<select name="status" class="admin-form__input admin-form__input--sm">
|
||||||
|
{{range $.StatusList}}
|
||||||
|
<option value="{{.Code}}" {{if eq .Code (orderNorm $orderStatus)}}selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn--primary btn--sm">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="admin-order-details">
|
||||||
|
<td colspan="6">
|
||||||
|
<strong>Адрес:</strong> {{.Address}}
|
||||||
|
{{if .Items}}
|
||||||
|
<ul class="admin-order-items">
|
||||||
|
{{range .Items}}
|
||||||
|
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="admin-empty">Заказов пока нет</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-status-legend">
|
||||||
|
<h3 class="admin-section__title">Статусы</h3>
|
||||||
|
<div class="admin-status-legend__grid">
|
||||||
|
{{range .StatusList}}
|
||||||
|
<span class="order-status {{orderClass .Code}}">{{.Label}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if ne .Status "cancelled"}}
|
{{if not (orderTerminal .Status)}}
|
||||||
<div class="order-timeline">
|
<div class="order-timeline">
|
||||||
{{range orderTimeline}}
|
{{range orderTimeline}}
|
||||||
<div class="order-timeline__step {{if stepCurrent $.Status .}}order-timeline__step--current{{end}} {{if stepDone $.Status .}}order-timeline__step--done{{end}}">
|
<div class="order-timeline__step {{if stepCurrent $.Status .}}order-timeline__step--current{{end}} {{if stepDone $.Status .}}order-timeline__step--done{{end}}">
|
||||||
|
|||||||
@@ -1,37 +1,58 @@
|
|||||||
package orderstatus
|
package orderstatus
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Label string
|
Label string
|
||||||
Class string
|
Class string
|
||||||
Step int
|
Step int
|
||||||
|
Terminal bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var statuses = map[string]Info{
|
var statuses = map[string]Info{
|
||||||
"new": {Label: "Новый", Class: "status--new", Step: 1},
|
"pending": {Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||||
"confirmed": {Label: "Подтверждён", Class: "status--confirmed", Step: 2},
|
"unpaid": {Label: "Не оплачено", Class: "status--unpaid", Step: 1},
|
||||||
|
"paid": {Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||||
"processing": {Label: "В обработке", Class: "status--processing", Step: 3},
|
"processing": {Label: "В обработке", Class: "status--processing", Step: 3},
|
||||||
"shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4},
|
"shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4},
|
||||||
"delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5},
|
"delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5},
|
||||||
"cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0},
|
"cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true},
|
||||||
|
"refunded": {Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true},
|
||||||
|
// совместимость со старыми заказами
|
||||||
|
"new": {Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||||
|
"confirmed": {Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
var Timeline = []string{"new", "confirmed", "processing", "shipped", "delivered"}
|
// Timeline — основной путь доставки (для таймлайна в кабинете)
|
||||||
|
var Timeline = []string{"pending", "paid", "processing", "shipped", "delivered"}
|
||||||
|
|
||||||
|
// AllCodes — все статусы для выбора в админке
|
||||||
|
var AllCodes = []string{
|
||||||
|
"pending", "unpaid", "paid", "processing", "shipped", "delivered", "cancelled", "refunded",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Normalize(code string) string {
|
||||||
|
if code == "new" {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
if code == "confirmed" {
|
||||||
|
return "paid"
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
func Get(code string) Info {
|
func Get(code string) Info {
|
||||||
|
code = Normalize(code)
|
||||||
if s, ok := statuses[code]; ok {
|
if s, ok := statuses[code]; ok {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return Info{Label: code, Class: "status--new", Step: 1}
|
return Info{Label: code, Class: "status--pending", Step: 1}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Label(code string) string {
|
func Label(code string) string { return Get(code).Label }
|
||||||
return Get(code).Label
|
func Class(code string) string { return Get(code).Class }
|
||||||
}
|
func Step(code string) int { return Get(code).Step }
|
||||||
|
func IsTerminal(code string) bool { return Get(code).Terminal }
|
||||||
|
|
||||||
func Class(code string) string {
|
func Valid(code string) bool {
|
||||||
return Get(code).Class
|
_, ok := statuses[Normalize(code)]
|
||||||
}
|
return ok
|
||||||
|
|
||||||
func Step(code string) int {
|
|
||||||
return Get(code).Step
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"shop/internal/models"
|
"shop/internal/models"
|
||||||
@@ -25,7 +26,7 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
|
|||||||
|
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status)
|
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'new')
|
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total,
|
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total,
|
||||||
).Scan(&order.ID, &order.CreatedAt)
|
).Scan(&order.ID, &order.CreatedAt)
|
||||||
@@ -91,3 +92,62 @@ func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.Orde
|
|||||||
}
|
}
|
||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||||
|
FROM orders ORDER BY created_at DESC LIMIT $1
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return r.collectOrders(ctx, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) GetByID(ctx context.Context, id int) (models.Order, error) {
|
||||||
|
var o models.Order
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||||
|
FROM orders WHERE id = $1
|
||||||
|
`, id).Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return models.Order{}, err
|
||||||
|
}
|
||||||
|
o.Items, err = r.items(ctx, o.ID)
|
||||||
|
return o, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) UpdateStatus(ctx context.Context, id int, status string) error {
|
||||||
|
tag, err := r.pool.Exec(ctx, `UPDATE orders SET status = $1 WHERE id = $2`, status, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM orders`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) collectOrders(ctx context.Context, rows pgx.Rows) ([]models.Order, error) {
|
||||||
|
var orders []models.Order
|
||||||
|
for rows.Next() {
|
||||||
|
var o models.Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
o.Items, err = r.items(ctx, o.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orders = append(orders, o)
|
||||||
|
}
|
||||||
|
return orders, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user