EOD and Carryover tickets added
This commit is contained in:
+6
-1
@@ -43,6 +43,11 @@ JIRA_TERMINAL_STATUSES=Cancelled,Waiting For Return,Device Return Not Needed
|
|||||||
# ticket newly transitions into one (comma-separated).
|
# ticket newly transitions into one (comma-separated).
|
||||||
CANCELLED_STATUSES=Cancelled
|
CANCELLED_STATUSES=Cancelled
|
||||||
# The two statuses your team uses at end-of-day close-out, depending on
|
# The two statuses your team uses at end-of-day close-out, depending on
|
||||||
# whether ShipStation's automation also generated a return label.
|
# whether ShipStation's automation also generated a return label. Reaching
|
||||||
|
# either one moves a ticket to the Done tab and out of the active view.
|
||||||
FULFILLED_STATUS_WITH_RETURN=Waiting For Return
|
FULFILLED_STATUS_WITH_RETURN=Waiting For Return
|
||||||
FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
|
FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
|
||||||
|
# Daily intake cutoff (24h HH:MM). Tickets created after this time on their
|
||||||
|
# arrival day are flagged "Past Cutoff" in the table and counted on the
|
||||||
|
# Dashboard - they didn't get a full window to be processed same-day.
|
||||||
|
INTAKE_CUTOFF_TIME=15:30
|
||||||
|
|||||||
@@ -65,6 +65,31 @@ existing tickets rather than duplicating them.
|
|||||||
Dashboard's Carryover count is just these tickets filtered to "created
|
Dashboard's Carryover count is just these tickets filtered to "created
|
||||||
before today."
|
before today."
|
||||||
|
|
||||||
|
## The Done pile
|
||||||
|
|
||||||
|
Once a ticket reaches either fulfilled status, it moves off the
|
||||||
|
**Active Orders** tab and onto the **Done** tab automatically - the
|
||||||
|
active view stays focused on what's still being worked. This is a
|
||||||
|
filter, not a physical move: everything's still the same `orders`
|
||||||
|
table, split by current status each time the view refreshes, so if a
|
||||||
|
status ever changed back there'd be nothing to reconcile. Cancelled
|
||||||
|
tickets stay on Active Orders (still need eyes on them) - only the two
|
||||||
|
fulfilled statuses trigger the move. The Dashboard's "Active Orders"
|
||||||
|
count and company breakdown only reflect what's still active;
|
||||||
|
"Fulfilled Today" and "Arrived Past Cutoff Today" look at all of
|
||||||
|
today's activity regardless of which tab something ended up on.
|
||||||
|
|
||||||
|
## Intake cutoff tracking
|
||||||
|
|
||||||
|
`INTAKE_CUTOFF_TIME` (default `15:30`, i.e. 3:30 PM) flags any ticket
|
||||||
|
created after that time on its arrival day with a **Past Cutoff** "Yes"
|
||||||
|
in the table, and the Dashboard shows how many of today's tickets
|
||||||
|
arrived past cutoff. Since a cutoff ticket is known to spill into
|
||||||
|
tomorrow the moment it arrives, it also counts toward **Carryover**
|
||||||
|
immediately - not just once the date actually rolls over - unless it
|
||||||
|
still gets fulfilled the same day despite arriving late, in which case
|
||||||
|
it correctly drops out of both.
|
||||||
|
|
||||||
## How tracking numbers get pulled and matched
|
## How tracking numbers get pulled and matched
|
||||||
|
|
||||||
Confirmed against a real ShipStation payload from your queue (not
|
Confirmed against a real ShipStation payload from your queue (not
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
|||||||
"Status Tracking",
|
"Status Tracking",
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
|
"INTAKE_CUTOFF_TIME": (
|
||||||
|
"Daily intake cutoff time, 24h HH:MM (e.g. 15:30 for 3:30 PM)",
|
||||||
|
"Status Tracking",
|
||||||
|
False,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_DB_URL = "sqlite:///orders.db"
|
DEFAULT_DB_URL = "sqlite:///orders.db"
|
||||||
@@ -83,6 +88,7 @@ DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health"
|
|||||||
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
||||||
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
||||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
||||||
|
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
||||||
DEFAULT_TERMINAL_STATUSES = (
|
DEFAULT_TERMINAL_STATUSES = (
|
||||||
f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}"
|
f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ class Order(Base):
|
|||||||
source_created_at = Column(DateTime, nullable=True)
|
source_created_at = Column(DateTime, nullable=True)
|
||||||
# When we pulled it into this app
|
# When we pulled it into this app
|
||||||
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow)
|
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow)
|
||||||
|
# When this ticket first transitioned into a fulfilled status - used
|
||||||
|
# for "fulfilled today" counts and as a rough audit trail. Set once,
|
||||||
|
# on the transition; not touched again while it stays fulfilled.
|
||||||
|
fulfilled_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Downstream pipeline flags - useful once Odoo export is fully wired up
|
# Downstream pipeline flags - useful once Odoo export is fully wired up
|
||||||
fulfilled = Column(Boolean, nullable=False, default=False)
|
fulfilled = Column(Boolean, nullable=False, default=False)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Daily intake-window helpers.
|
||||||
|
|
||||||
|
Intake runs on a schedule (8AM-3:30PM by default), so it's useful to
|
||||||
|
know when a ticket arrived relative to the cutoff - a ticket created
|
||||||
|
after INTAKE_CUTOFF_TIME didn't have a full window to get processed
|
||||||
|
same-day, which helps explain carryover rather than just seeing it as
|
||||||
|
unexplained backlog.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cutoff_time(raw: str) -> Optional[dt.time]:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
hour_str, minute_str = raw.split(":")
|
||||||
|
return dt.time(int(hour_str), int(minute_str))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cutoff_time() -> Optional[dt.time]:
|
||||||
|
return parse_cutoff_time(
|
||||||
|
config.get("INTAKE_CUTOFF_TIME", config.DEFAULT_INTAKE_CUTOFF_TIME)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_past_cutoff(created_at: Optional[dt.datetime], cutoff: Optional[dt.time]) -> bool:
|
||||||
|
if not created_at or not cutoff:
|
||||||
|
return False
|
||||||
|
return created_at.time() > cutoff
|
||||||
@@ -9,6 +9,21 @@ from __future__ import annotations
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.status_rules import parse_status_list
|
||||||
|
|
||||||
|
|
||||||
|
def get_fulfilled_statuses() -> set[str]:
|
||||||
|
"""The two statuses that mean 'done, move to the Done pile'."""
|
||||||
|
return parse_status_list(
|
||||||
|
",".join(
|
||||||
|
[
|
||||||
|
config.get("FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN),
|
||||||
|
config.get(
|
||||||
|
"FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def suggest_jira_status(tracking_numbers: List[dict]) -> Optional[str]:
|
def suggest_jira_status(tracking_numbers: List[dict]) -> Optional[str]:
|
||||||
|
|||||||
+22
-6
@@ -33,7 +33,7 @@ from app.ui.settings_dialog import SettingsDialog
|
|||||||
from app.ui.widgets.orders_table import OrdersTableView
|
from app.ui.widgets.orders_table import OrdersTableView
|
||||||
from app.ui.widgets.dashboard import DashboardWidget
|
from app.ui.widgets.dashboard import DashboardWidget
|
||||||
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
||||||
from app.workers import FetchOrdersWorker, load_all_orders, get_dashboard_stats
|
from app.workers import FetchOrdersWorker, load_active_and_done_orders, get_dashboard_stats
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -58,8 +58,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.dashboard = DashboardWidget()
|
self.dashboard = DashboardWidget()
|
||||||
self.orders_table = OrdersTableView()
|
self.orders_table = OrdersTableView()
|
||||||
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
|
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
|
||||||
|
self.done_table = OrdersTableView()
|
||||||
|
self.done_table.order_double_clicked.connect(self._on_order_double_clicked)
|
||||||
self.tabs.addTab(self.dashboard, "Dashboard")
|
self.tabs.addTab(self.dashboard, "Dashboard")
|
||||||
self.tabs.addTab(self.orders_table, "All Orders")
|
self.tabs.addTab(self.orders_table, "Active Orders")
|
||||||
|
self.tabs.addTab(self.done_table, "Done")
|
||||||
layout.addWidget(self.tabs)
|
layout.addWidget(self.tabs)
|
||||||
|
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
@@ -81,6 +84,7 @@ class MainWindow(QMainWindow):
|
|||||||
toolbar.addSeparator()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
export_action = QAction("Export Visible Orders to Odoo CSV", self)
|
export_action = QAction("Export Visible Orders to Odoo CSV", self)
|
||||||
|
export_action.setToolTip("Exports from whichever of Active Orders / Done is open")
|
||||||
export_action.triggered.connect(self._on_export_clicked)
|
export_action.triggered.connect(self._on_export_clicked)
|
||||||
toolbar.addAction(export_action)
|
toolbar.addAction(export_action)
|
||||||
|
|
||||||
@@ -194,7 +198,16 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
|
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
|
||||||
|
|
||||||
def _on_export_clicked(self) -> None:
|
def _on_export_clicked(self) -> None:
|
||||||
orders = self.orders_table.visible_orders()
|
current_tab = self.tabs.currentWidget()
|
||||||
|
if not isinstance(current_tab, OrdersTableView):
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Nothing to export",
|
||||||
|
"Switch to the Active Orders or Done tab first, then export.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
orders = current_tab.visible_orders()
|
||||||
if not orders:
|
if not orders:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "Nothing to export", "No orders match the current filters."
|
self, "Nothing to export", "No orders match the current filters."
|
||||||
@@ -216,11 +229,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
|
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
|
||||||
|
|
||||||
def _refresh_everything(self) -> None:
|
def _refresh_everything(self) -> None:
|
||||||
orders = load_all_orders()
|
active_orders, done_orders = load_active_and_done_orders()
|
||||||
self.orders_table.set_orders(orders)
|
self.orders_table.set_orders(active_orders)
|
||||||
|
self.done_table.set_orders(done_orders)
|
||||||
self.dashboard.update_stats(get_dashboard_stats())
|
self.dashboard.update_stats(get_dashboard_stats())
|
||||||
if not hasattr(self, "_suppress_ready_status"):
|
if not hasattr(self, "_suppress_ready_status"):
|
||||||
self.status_label.setText(f"{len(orders)} order(s) in local database.")
|
self.status_label.setText(
|
||||||
|
f"{len(active_orders)} active order(s), {len(done_orders)} done."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+17
-13
@@ -59,28 +59,31 @@ class DashboardWidget(QWidget):
|
|||||||
heading.setFont(heading_font)
|
heading.setFont(heading_font)
|
||||||
outer.addWidget(heading)
|
outer.addWidget(heading)
|
||||||
|
|
||||||
# Top row: overall pipeline health
|
# Top row: active workload health (fulfilled tickets have moved to the Done tab)
|
||||||
top_row = QHBoxLayout()
|
top_row = QHBoxLayout()
|
||||||
self.total_card = StatCard("Total Orders")
|
self.total_card = StatCard("Active Orders")
|
||||||
self.fulfilled_card = StatCard("Fulfilled")
|
|
||||||
self.fulfilled_card.setStyleSheet("QLabel { color: #1a7a1a; }")
|
|
||||||
self.cancelled_card = StatCard("Cancelled")
|
self.cancelled_card = StatCard("Cancelled")
|
||||||
self.cancelled_card.setStyleSheet("QLabel { color: #b00020; }")
|
self.cancelled_card.setStyleSheet("QLabel { color: #b00020; }")
|
||||||
self.carryover_card = StatCard("Carryover (still open from an earlier day)")
|
self.carryover_card = StatCard("Carryover (still open from an earlier day)")
|
||||||
self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }")
|
self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }")
|
||||||
for card in (self.total_card, self.fulfilled_card, self.cancelled_card, self.carryover_card):
|
self.past_cutoff_card = StatCard("Arrived Past Cutoff Today")
|
||||||
|
self.past_cutoff_card.setStyleSheet("QLabel { color: #b06a00; }")
|
||||||
|
for card in (self.total_card, self.cancelled_card, self.carryover_card, self.past_cutoff_card):
|
||||||
top_row.addWidget(card)
|
top_row.addWidget(card)
|
||||||
outer.addLayout(top_row)
|
outer.addLayout(top_row)
|
||||||
|
|
||||||
# Second row: tracking pull progress, since that's the 5PM task
|
# Second row: today's completions + tracking pull progress
|
||||||
outer.addWidget(self._section_label("Tracking Numbers"))
|
outer.addWidget(self._section_label("Today's Progress"))
|
||||||
tracking_row = QHBoxLayout()
|
progress_row = QHBoxLayout()
|
||||||
self.tracking_received_card = StatCard("Tickets With Tracking Pulled")
|
self.fulfilled_today_card = StatCard("Fulfilled Today (moved to Done)")
|
||||||
tracking_row.addWidget(self.tracking_received_card)
|
self.fulfilled_today_card.setStyleSheet("QLabel { color: #1a7a1a; }")
|
||||||
outer.addLayout(tracking_row)
|
self.tracking_received_card = StatCard("Active Tickets With Tracking Pulled")
|
||||||
|
progress_row.addWidget(self.fulfilled_today_card)
|
||||||
|
progress_row.addWidget(self.tracking_received_card)
|
||||||
|
outer.addLayout(progress_row)
|
||||||
|
|
||||||
# Third row: per-company totals (grid so adding a 3rd company later just works)
|
# Third row: per-company totals (grid so adding a 3rd company later just works)
|
||||||
outer.addWidget(self._section_label("By Company"))
|
outer.addWidget(self._section_label("Active Orders By Company"))
|
||||||
self.company_grid = QGridLayout()
|
self.company_grid = QGridLayout()
|
||||||
self.company_cards: dict[str, StatCard] = {}
|
self.company_cards: dict[str, StatCard] = {}
|
||||||
outer.addLayout(self.company_grid)
|
outer.addLayout(self.company_grid)
|
||||||
@@ -97,9 +100,10 @@ class DashboardWidget(QWidget):
|
|||||||
|
|
||||||
def update_stats(self, stats: dict) -> None:
|
def update_stats(self, stats: dict) -> None:
|
||||||
self.total_card.set_value(stats.get("total", 0))
|
self.total_card.set_value(stats.get("total", 0))
|
||||||
self.fulfilled_card.set_value(stats.get("fulfilled_count", 0))
|
|
||||||
self.cancelled_card.set_value(stats.get("cancelled_count", 0))
|
self.cancelled_card.set_value(stats.get("cancelled_count", 0))
|
||||||
self.carryover_card.set_value(stats.get("carryover_count", 0))
|
self.carryover_card.set_value(stats.get("carryover_count", 0))
|
||||||
|
self.past_cutoff_card.set_value(stats.get("past_cutoff_today_count", 0))
|
||||||
|
self.fulfilled_today_card.set_value(stats.get("fulfilled_today_count", 0))
|
||||||
self.tracking_received_card.set_value(stats.get("tracking_received_count", 0))
|
self.tracking_received_card.set_value(stats.get("tracking_received_count", 0))
|
||||||
|
|
||||||
by_company = stats.get("by_company", {})
|
by_company = stats.get("by_company", {})
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ from PyQt6.QtWidgets import (
|
|||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
|
from app.schedule import get_cutoff_time, is_past_cutoff
|
||||||
from app.status_rules import parse_status_list, status_in
|
from app.status_rules import parse_status_list, status_in
|
||||||
|
from app.tracking import get_fulfilled_statuses
|
||||||
|
|
||||||
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
|
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
|
||||||
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
||||||
@@ -40,7 +42,8 @@ COLUMNS = [
|
|||||||
("summary", "Summary"),
|
("summary", "Summary"),
|
||||||
("status", "Status"),
|
("status", "Status"),
|
||||||
("tracking_display", "Tracking #"),
|
("tracking_display", "Tracking #"),
|
||||||
("source_created_at", "Created"),
|
("created_display", "Created"),
|
||||||
|
("past_cutoff_display", "Past Cutoff"),
|
||||||
]
|
]
|
||||||
|
|
||||||
ALL_COMPANIES = "All Companies"
|
ALL_COMPANIES = "All Companies"
|
||||||
@@ -55,31 +58,27 @@ def _format_tracking(tracking_numbers) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_created(source_created_at) -> str:
|
||||||
|
if not source_created_at:
|
||||||
|
return ""
|
||||||
|
return source_created_at.strftime("%b %d, %I:%M %p").replace(" 0", " ")
|
||||||
|
|
||||||
|
|
||||||
class OrdersTableModel(QAbstractTableModel):
|
class OrdersTableModel(QAbstractTableModel):
|
||||||
def __init__(self, orders: List[Order] | None = None, parent=None):
|
def __init__(self, orders: List[Order] | None = None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._orders: List[Order] = orders or []
|
self._orders: List[Order] = orders or []
|
||||||
self._cancelled_statuses: set[str] = set()
|
self._cancelled_statuses: set[str] = set()
|
||||||
self._fulfilled_statuses: set[str] = set()
|
self._fulfilled_statuses: set[str] = set()
|
||||||
|
self._cutoff_time = None
|
||||||
|
|
||||||
def set_orders(self, orders: List[Order]) -> None:
|
def set_orders(self, orders: List[Order]) -> None:
|
||||||
# Re-read status lists each refresh, in case Settings changed.
|
# Re-read status lists and cutoff time each refresh, in case Settings changed.
|
||||||
self._cancelled_statuses = parse_status_list(
|
self._cancelled_statuses = parse_status_list(
|
||||||
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
||||||
)
|
)
|
||||||
self._fulfilled_statuses = parse_status_list(
|
self._fulfilled_statuses = get_fulfilled_statuses()
|
||||||
",".join(
|
self._cutoff_time = get_cutoff_time()
|
||||||
[
|
|
||||||
config.get(
|
|
||||||
"FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN
|
|
||||||
),
|
|
||||||
config.get(
|
|
||||||
"FULFILLED_STATUS_WITHOUT_RETURN",
|
|
||||||
config.DEFAULT_FULFILLED_WITHOUT_RETURN,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
self._orders = orders
|
self._orders = orders
|
||||||
self.endResetModel()
|
self.endResetModel()
|
||||||
@@ -119,6 +118,10 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
return ", ".join(order.skus or [])
|
return ", ".join(order.skus or [])
|
||||||
if field_name == "tracking_display":
|
if field_name == "tracking_display":
|
||||||
return _format_tracking(order.tracking_numbers)
|
return _format_tracking(order.tracking_numbers)
|
||||||
|
if field_name == "created_display":
|
||||||
|
return _format_created(order.source_created_at)
|
||||||
|
if field_name == "past_cutoff_display":
|
||||||
|
return "Yes" if is_past_cutoff(order.source_created_at, self._cutoff_time) else ""
|
||||||
|
|
||||||
value = getattr(order, field_name)
|
value = getattr(order, field_name)
|
||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|||||||
+73
-24
@@ -9,7 +9,7 @@ pattern rather than blocking the UI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from typing import List, TypedDict
|
from typing import List, Tuple, TypedDict
|
||||||
|
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -17,8 +17,10 @@ from sqlalchemy import select
|
|||||||
from app import config
|
from app import config
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
|
from app.schedule import get_cutoff_time, is_past_cutoff
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
from app.status_rules import parse_status_list, status_in
|
from app.status_rules import parse_status_list, status_in
|
||||||
|
from app.tracking import get_fulfilled_statuses
|
||||||
|
|
||||||
|
|
||||||
class StatusChange(TypedDict):
|
class StatusChange(TypedDict):
|
||||||
@@ -69,6 +71,11 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
existing JIRA row for that ticket. If no such row exists locally yet,
|
existing JIRA row for that ticket. If no such row exists locally yet,
|
||||||
the ticket number is reported back as unmatched instead of silently
|
the ticket number is reported back as unmatched instead of silently
|
||||||
dropped.
|
dropped.
|
||||||
|
|
||||||
|
Whenever a JIRA ticket's status transitions INTO a fulfilled status
|
||||||
|
(Waiting For Return / Device Return Not Needed), fulfilled_at is
|
||||||
|
stamped - that's what lets the dashboard show "fulfilled today" and
|
||||||
|
what moves it into the Done pile.
|
||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
new_count = 0
|
new_count = 0
|
||||||
@@ -76,6 +83,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
enriched_count = 0
|
enriched_count = 0
|
||||||
status_changes: List[StatusChange] = []
|
status_changes: List[StatusChange] = []
|
||||||
unmatched_tracking_tickets: List[str] = []
|
unmatched_tracking_tickets: List[str] = []
|
||||||
|
fulfilled_statuses = get_fulfilled_statuses()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for order in orders:
|
for order in orders:
|
||||||
@@ -103,6 +111,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
new_status = order["status"]
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
session.add(
|
session.add(
|
||||||
Order(
|
Order(
|
||||||
@@ -112,15 +122,17 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
company=order.get("company", "Unknown"),
|
company=order.get("company", "Unknown"),
|
||||||
skus=order.get("skus", []),
|
skus=order.get("skus", []),
|
||||||
summary=order["summary"],
|
summary=order["summary"],
|
||||||
status=order["status"],
|
status=new_status,
|
||||||
source_created_at=order["source_created_at"],
|
source_created_at=order["source_created_at"],
|
||||||
raw_data=order["raw_data"],
|
raw_data=order["raw_data"],
|
||||||
|
fulfilled_at=(
|
||||||
|
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
new_count += 1
|
new_count += 1
|
||||||
else:
|
else:
|
||||||
old_status = existing.status
|
old_status = existing.status
|
||||||
new_status = order["status"]
|
|
||||||
if old_status != new_status:
|
if old_status != new_status:
|
||||||
status_changes.append(
|
status_changes.append(
|
||||||
StatusChange(
|
StatusChange(
|
||||||
@@ -129,6 +141,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
new_status=new_status,
|
new_status=new_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if status_in(new_status, fulfilled_statuses) and not status_in(
|
||||||
|
old_status, fulfilled_statuses
|
||||||
|
):
|
||||||
|
existing.fulfilled_at = dt.datetime.utcnow()
|
||||||
|
|
||||||
existing.ticket_number = order.get("ticket_number")
|
existing.ticket_number = order.get("ticket_number")
|
||||||
existing.company = order.get("company", "Unknown")
|
existing.company = order.get("company", "Unknown")
|
||||||
@@ -162,46 +178,59 @@ def load_all_orders() -> List[Order]:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def split_active_and_done(orders: List[Order]) -> Tuple[List[Order], List[Order]]:
|
||||||
|
"""
|
||||||
|
Active = still being worked (includes cancelled - those still need
|
||||||
|
eyes on them). Done = fulfilled (Waiting For Return / Device Return
|
||||||
|
Not Needed) - archived out of the working view on purpose, per how
|
||||||
|
the team wants to keep the active list focused on what's at hand.
|
||||||
|
"""
|
||||||
|
fulfilled_statuses = get_fulfilled_statuses()
|
||||||
|
active, done = [], []
|
||||||
|
for order in orders:
|
||||||
|
(done if status_in(order.status, fulfilled_statuses) else active).append(order)
|
||||||
|
return active, done
|
||||||
|
|
||||||
|
|
||||||
|
def load_active_and_done_orders() -> Tuple[List[Order], List[Order]]:
|
||||||
|
return split_active_and_done(load_all_orders())
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard_stats() -> dict:
|
def get_dashboard_stats() -> dict:
|
||||||
"""
|
"""
|
||||||
Counts for the dashboard: totals by company, how many are cancelled,
|
Counts for the dashboard. "Active Orders" and the company breakdown
|
||||||
fulfilled, still-open-from-a-previous-day (carryover), and how many
|
reflect only the active workload (fulfilled tickets have moved to
|
||||||
have had tracking numbers pulled yet.
|
the Done pile and don't clutter this). Fulfilled-today and
|
||||||
|
past-cutoff-today look across ALL of today's tickets regardless of
|
||||||
|
which pile they're in now, since both are about what happened today.
|
||||||
|
|
||||||
|
Carryover counts any still-open ticket that's already known to spill
|
||||||
|
into tomorrow - either it's genuinely left over from a prior day, or
|
||||||
|
it arrived today but after the cutoff (same effect, just known a day
|
||||||
|
earlier).
|
||||||
"""
|
"""
|
||||||
orders = load_all_orders()
|
orders = load_all_orders()
|
||||||
|
active_orders, _done_orders = split_active_and_done(orders)
|
||||||
|
|
||||||
cancelled_statuses = parse_status_list(
|
cancelled_statuses = parse_status_list(
|
||||||
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
||||||
)
|
)
|
||||||
fulfilled_statuses = parse_status_list(
|
|
||||||
",".join(
|
|
||||||
[
|
|
||||||
config.get("FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN),
|
|
||||||
config.get(
|
|
||||||
"FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
terminal_statuses = parse_status_list(
|
terminal_statuses = parse_status_list(
|
||||||
config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES)
|
config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES)
|
||||||
)
|
)
|
||||||
|
cutoff = get_cutoff_time()
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
|
|
||||||
by_company: dict[str, int] = {}
|
by_company: dict[str, int] = {}
|
||||||
cancelled_count = 0
|
cancelled_count = 0
|
||||||
fulfilled_count = 0
|
|
||||||
carryover_count = 0
|
carryover_count = 0
|
||||||
tracking_received_count = 0
|
tracking_received_count = 0
|
||||||
|
|
||||||
for order in orders:
|
for order in active_orders:
|
||||||
by_company[order.company] = by_company.get(order.company, 0) + 1
|
by_company[order.company] = by_company.get(order.company, 0) + 1
|
||||||
|
|
||||||
if status_in(order.status, cancelled_statuses):
|
if status_in(order.status, cancelled_statuses):
|
||||||
cancelled_count += 1
|
cancelled_count += 1
|
||||||
if status_in(order.status, fulfilled_statuses):
|
|
||||||
fulfilled_count += 1
|
|
||||||
if order.tracking_numbers:
|
if order.tracking_numbers:
|
||||||
tracking_received_count += 1
|
tracking_received_count += 1
|
||||||
|
|
||||||
@@ -209,14 +238,34 @@ def get_dashboard_stats() -> dict:
|
|||||||
created_before_today = bool(
|
created_before_today = bool(
|
||||||
order.source_created_at and order.source_created_at.date() < today
|
order.source_created_at and order.source_created_at.date() < today
|
||||||
)
|
)
|
||||||
if still_open and created_before_today:
|
# A ticket that arrived today but after the cutoff won't get worked
|
||||||
|
# today either - it's known carryover before the date even rolls
|
||||||
|
# over, not just once tomorrow arrives.
|
||||||
|
arrived_past_cutoff_today = bool(
|
||||||
|
order.source_created_at
|
||||||
|
and order.source_created_at.date() == today
|
||||||
|
and is_past_cutoff(order.source_created_at, cutoff)
|
||||||
|
)
|
||||||
|
if still_open and (created_before_today or arrived_past_cutoff_today):
|
||||||
carryover_count += 1
|
carryover_count += 1
|
||||||
|
|
||||||
|
fulfilled_today_count = sum(
|
||||||
|
1 for o in orders if o.fulfilled_at and o.fulfilled_at.date() == today
|
||||||
|
)
|
||||||
|
past_cutoff_today_count = sum(
|
||||||
|
1
|
||||||
|
for o in orders
|
||||||
|
if o.source_created_at
|
||||||
|
and o.source_created_at.date() == today
|
||||||
|
and is_past_cutoff(o.source_created_at, cutoff)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": len(orders),
|
"total": len(active_orders),
|
||||||
"by_company": by_company,
|
"by_company": by_company,
|
||||||
"cancelled_count": cancelled_count,
|
"cancelled_count": cancelled_count,
|
||||||
"fulfilled_count": fulfilled_count,
|
|
||||||
"carryover_count": carryover_count,
|
"carryover_count": carryover_count,
|
||||||
"tracking_received_count": tracking_received_count,
|
"tracking_received_count": tracking_received_count,
|
||||||
|
"fulfilled_today_count": fulfilled_today_count,
|
||||||
|
"past_cutoff_today_count": past_cutoff_today_count,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user