food-market/app/ui/chart.py
Claude 38e9614a25 Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.

Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.

Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.

График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.

Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:33:11 +03:00

137 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Столбчатый график на QPainter.
QtCharts исключён из сборки: он тянет за собой десятки мегабайт ради одной
диаграммы. Здесь нужен один тип графика, и нарисовать его вручную дешевле,
чем удвоить размер exe.
Умеет то, без чего график врал бы: отрицательные значения (убыток — это
нормально) и нулевую линию на своём месте.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPen
from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget
from .. import money as m
from . import theme
PADDING_LEFT = 8
PADDING_RIGHT = 8
PADDING_TOP = 18
LABEL_HEIGHT = 34
MIN_BAR_WIDTH = 14
BAR_GAP = 6
@dataclass
class Bar:
label: str
value: Decimal
tooltip: str = ""
class BarChart(QWidget):
"""Столбики со знаком. Подпись под каждым, значение — во всплывающей подсказке."""
def __init__(self, parent=None):
super().__init__(parent)
self.bars: list[Bar] = []
self.currency = ""
self.positive = QColor(theme.ACCENT)
self.negative = QColor(theme.DANGER)
self._rects: list[tuple[QRectF, Bar]] = []
self.setMinimumHeight(190)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setMouseTracking(True)
def set_bars(self, bars: list[Bar], currency: str = "", positive: str = "") -> None:
self.bars = list(bars)
self.currency = currency
if positive:
self.positive = QColor(positive)
self.update()
# --- отрисовка ---
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
self._rects.clear()
area = QRectF(self.rect()).adjusted(PADDING_LEFT, PADDING_TOP, -PADDING_RIGHT, -LABEL_HEIGHT)
if not self.bars or area.height() <= 0:
painter.setPen(QColor(theme.TEXT_DIM))
painter.drawText(self.rect(), Qt.AlignCenter, "Пока нечего показывать")
return
values = [float(bar.value) for bar in self.bars]
top = max(max(values), 0.0)
bottom = min(min(values), 0.0)
span = top - bottom
if span <= 0:
# Все значения нулевые: рисуем только базовую линию.
span, top = 1.0, 1.0
zero_y = area.bottom() - (0.0 - bottom) / span * area.height()
slot = area.width() / len(self.bars)
width = max(MIN_BAR_WIDTH, min(slot - BAR_GAP, 64.0))
self._draw_zero_line(painter, area, zero_y, bottom < 0)
metrics = QFontMetrics(painter.font())
for index, bar in enumerate(self.bars):
center = area.left() + slot * (index + 0.5)
height = abs(float(bar.value)) / span * area.height()
rect = QRectF(
center - width / 2,
zero_y - height if bar.value >= 0 else zero_y,
width,
max(height, 1.0),
)
color = self.positive if bar.value >= 0 else self.negative
painter.setPen(Qt.NoPen)
painter.setBrush(color)
painter.drawRoundedRect(rect, 3, 3)
self._rects.append((rect, bar))
self._draw_label(painter, metrics, bar, center, slot)
painter.end()
def _draw_zero_line(self, painter: QPainter, area: QRectF, zero_y: float, visible: bool) -> None:
pen = QPen(QColor(theme.BORDER))
pen.setStyle(Qt.SolidLine if visible else Qt.DotLine)
painter.setPen(pen)
painter.drawLine(QPointF(area.left(), zero_y), QPointF(area.right(), zero_y))
def _draw_label(self, painter: QPainter, metrics: QFontMetrics, bar: Bar, center: float, slot: float) -> None:
painter.setPen(QColor(theme.TEXT_DIM))
text = metrics.elidedText(bar.label, Qt.ElideRight, int(slot) - 2)
painter.drawText(
QRectF(center - slot / 2, self.height() - LABEL_HEIGHT + 4, slot, LABEL_HEIGHT - 6),
Qt.AlignHCenter | Qt.AlignTop,
text,
)
# --- подсказка ---
def mouseMoveEvent(self, event) -> None:
position = event.position()
for rect, bar in self._rects:
# Ловим по всей высоте столбца: попасть мышью в тонкую полоску
# у нуля невозможно.
if rect.left() <= position.x() <= rect.right():
QToolTip.showText(
event.globalPosition().toPoint(),
bar.tooltip or f"{bar.label}: {m.fmt_money(bar.value, self.currency)}",
self,
)
return
QToolTip.hideText()