Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
"""Столбчатый график на 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()
|