Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6655b02f4a | ||
|
|
bf5566e911 |
@@ -210,6 +210,21 @@ summary. Company is resolved from the SKU prefix (`SH`/`OK`) via
|
|||||||
usually blank, the app falls back to the joined deliverable
|
usually blank, the app falls back to the joined deliverable
|
||||||
descriptions for the Summary column when there's nothing else there.
|
descriptions for the Summary column when there's nothing else there.
|
||||||
|
|
||||||
|
## Resetting the local database
|
||||||
|
|
||||||
|
**Reset Local Database** in the toolbar clears every cached order (with
|
||||||
|
a confirmation first). This is always safe - JIRA is the real source of
|
||||||
|
truth, this is just a rebuildable cache - but it's worth knowing when
|
||||||
|
you'd actually need it: if a ticket transitioned status under an older,
|
||||||
|
buggy version of this app, its `fulfilled_at`/`cancelled_at` timestamp
|
||||||
|
can get stuck wrong, since those are only recalculated **at the moment
|
||||||
|
of a transition** - re-importing an already-transitioned ticket finds
|
||||||
|
"no status change" and never touches that timestamp again. A reset
|
||||||
|
clears the stale value entirely; the next Import from JIRA then stamps
|
||||||
|
everything correctly from scratch. Follow a reset with **Import from
|
||||||
|
JIRA**, and **Pull Tracking Numbers** if you rely on today's
|
||||||
|
already-pulled tracking data.
|
||||||
|
|
||||||
## Moving storage to your MariaDB LXC later
|
## Moving storage to your MariaDB LXC later
|
||||||
|
|
||||||
In Settings (or directly in `.env`), change:
|
In Settings (or directly in `.env`), change:
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ class Order(Base):
|
|||||||
# When the order/ticket was created in the source system
|
# When the order/ticket was created in the source system
|
||||||
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.now)
|
||||||
# When this ticket first transitioned into a fulfilled status - used
|
# When this ticket first transitioned into a fulfilled status - used
|
||||||
# for "fulfilled today" counts and as a rough audit trail. Set once,
|
# for "fulfilled today" counts and as a rough audit trail. Set once,
|
||||||
# on the transition; not touched again while it stays fulfilled.
|
# on the transition; not touched again while it stays fulfilled.
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ from app.queries import get_open_ticket_numbers
|
|||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
from app.status_rules import get_active_statuses
|
from app.status_rules import get_active_statuses
|
||||||
|
|
||||||
SEARCH_PAGE_SIZE = 50
|
SEARCH_PAGE_SIZE = 100
|
||||||
REQUEST_TIMEOUT_SECONDS = 30
|
REQUEST_TIMEOUT_SECONDS = 30
|
||||||
|
# Hard ceiling on pages per fetch, purely as a backstop against the new
|
||||||
|
# search endpoint's documented pagination flakiness (tokens that repeat
|
||||||
|
# or never advance) - normal usage should never come close to this.
|
||||||
|
MAX_SEARCH_PAGES = 200
|
||||||
|
|
||||||
# Deliverable values come back like "SH011: Shipping - Return Label iPad -
|
# Deliverable values come back like "SH011: Shipping - Return Label iPad -
|
||||||
# Physical in Box" - the part before the colon is the actual SKU code;
|
# Physical in Box" - the part before the colon is the actual SKU code;
|
||||||
@@ -138,7 +142,23 @@ class JiraService(OrderService):
|
|||||||
return combined_where + order_by_clause
|
return combined_where + order_by_clause
|
||||||
|
|
||||||
def _search_all_issues(self, jql: str) -> List[dict]:
|
def _search_all_issues(self, jql: str) -> List[dict]:
|
||||||
url = f"{self.base_url}/rest/api/3/search"
|
# /rest/api/3/search (with startAt/total pagination) has been
|
||||||
|
# deprecated and removed by Atlassian - /rest/api/3/search/jql is
|
||||||
|
# the replacement, using nextPageToken instead. That migration is
|
||||||
|
# NOT optional: hitting the old endpoint either errors outright or,
|
||||||
|
# worse, silently stops returning `total`, which made the old
|
||||||
|
# startAt-based loop here think page 1 was the whole result set -
|
||||||
|
# exactly the "only pulled tickets from this afternoon onward" bug,
|
||||||
|
# since with newest-first sorting, page 1 is just the most recent
|
||||||
|
# slice.
|
||||||
|
#
|
||||||
|
# The new endpoint's token pagination has its own documented
|
||||||
|
# flakiness in the wild (tokens that repeat or never advance), so
|
||||||
|
# this loop defends against that directly: it stops on an empty
|
||||||
|
# batch, an explicit isLast, a missing token, OR a token identical
|
||||||
|
# to one already seen - plus a hard page-count ceiling as a last
|
||||||
|
# resort so a pagination bug can never turn into an infinite loop.
|
||||||
|
url = f"{self.base_url}/rest/api/3/search/jql"
|
||||||
auth = (self.email, self.api_token)
|
auth = (self.email, self.api_token)
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
|
|
||||||
@@ -152,15 +172,19 @@ class JiraService(OrderService):
|
|||||||
fields += "," + ",".join(contact_field_ids)
|
fields += "," + ",".join(contact_field_ids)
|
||||||
|
|
||||||
all_issues: List[dict] = []
|
all_issues: List[dict] = []
|
||||||
start_at = 0
|
next_page_token: str | None = None
|
||||||
|
seen_tokens: set[str] = set()
|
||||||
|
page_count = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
params = {
|
params = {
|
||||||
"jql": jql,
|
"jql": jql,
|
||||||
"startAt": start_at,
|
|
||||||
"maxResults": SEARCH_PAGE_SIZE,
|
"maxResults": SEARCH_PAGE_SIZE,
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
}
|
}
|
||||||
|
if next_page_token:
|
||||||
|
params["nextPageToken"] = next_page_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url,
|
url,
|
||||||
@@ -193,12 +217,24 @@ class JiraService(OrderService):
|
|||||||
|
|
||||||
batch = data.get("issues", [])
|
batch = data.get("issues", [])
|
||||||
all_issues.extend(batch)
|
all_issues.extend(batch)
|
||||||
|
page_count += 1
|
||||||
|
|
||||||
total = data.get("total", len(all_issues))
|
new_token = data.get("nextPageToken")
|
||||||
start_at += len(batch)
|
is_last = bool(data.get("isLast")) or not new_token
|
||||||
if start_at >= total or not batch:
|
|
||||||
|
if (
|
||||||
|
not batch
|
||||||
|
or is_last
|
||||||
|
or not new_token
|
||||||
|
or new_token == next_page_token
|
||||||
|
or new_token in seen_tokens
|
||||||
|
or page_count >= MAX_SEARCH_PAGES
|
||||||
|
):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
seen_tokens.add(new_token)
|
||||||
|
next_page_token = new_token
|
||||||
|
|
||||||
return all_issues
|
return all_issues
|
||||||
|
|
||||||
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
|
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from app.workers import (
|
|||||||
load_orders_by_view,
|
load_orders_by_view,
|
||||||
get_dashboard_stats,
|
get_dashboard_stats,
|
||||||
mark_shipstation_sent,
|
mark_shipstation_sent,
|
||||||
|
reset_local_database,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +116,11 @@ class MainWindow(QMainWindow):
|
|||||||
refresh_action.triggered.connect(self._refresh_everything)
|
refresh_action.triggered.connect(self._refresh_everything)
|
||||||
toolbar.addAction(refresh_action)
|
toolbar.addAction(refresh_action)
|
||||||
|
|
||||||
|
reset_action = QAction("Reset Local Database", self)
|
||||||
|
reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward")
|
||||||
|
reset_action.triggered.connect(self._on_reset_database_clicked)
|
||||||
|
toolbar.addAction(reset_action)
|
||||||
|
|
||||||
self.status_bar = QStatusBar()
|
self.status_bar = QStatusBar()
|
||||||
self.setStatusBar(self.status_bar)
|
self.setStatusBar(self.status_bar)
|
||||||
self.status_label = QLabel("Ready.")
|
self.status_label = QLabel("Ready.")
|
||||||
@@ -333,6 +339,27 @@ class MainWindow(QMainWindow):
|
|||||||
"Upload this into ShipStation's Import Orders wizard.",
|
"Upload this into ShipStation's Import Orders wizard.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_reset_database_clicked(self) -> None:
|
||||||
|
confirm = QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Reset Local Database",
|
||||||
|
"This clears every locally cached order (JIRA is still the real source of "
|
||||||
|
"truth, so nothing there is affected). You'll need to click Import from JIRA "
|
||||||
|
"afterward to repopulate, and Pull Tracking Numbers again if you rely on "
|
||||||
|
"today's already-pulled tracking data.\n\n"
|
||||||
|
"Continue?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if confirm != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = reset_local_database()
|
||||||
|
self._refresh_everything()
|
||||||
|
self.status_label.setText(
|
||||||
|
f"Local database reset - {count} order(s) cleared. Run Import from JIRA to repopulate."
|
||||||
|
)
|
||||||
|
|
||||||
def _refresh_everything(self) -> None:
|
def _refresh_everything(self) -> None:
|
||||||
active_orders, cancelled_orders, done_orders = load_orders_by_view()
|
active_orders, cancelled_orders, done_orders = load_orders_by_view()
|
||||||
self.orders_table.set_orders(active_orders)
|
self.orders_table.set_orders(active_orders)
|
||||||
|
|||||||
+36
-6
@@ -12,7 +12,7 @@ import datetime as dt
|
|||||||
from typing import List, Tuple, 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, delete
|
||||||
|
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
@@ -102,6 +102,13 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
what moves it into the Done pile. cancelled_at works the same way for
|
what moves it into the Done pile. cancelled_at works the same way for
|
||||||
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
|
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
|
||||||
today" rather than showing every cancelled ticket ever.
|
today" rather than showing every cancelled ticket ever.
|
||||||
|
|
||||||
|
These get stamped with dt.datetime.now() (LOCAL time), not utcnow() -
|
||||||
|
deliberately, since every "is this today" check elsewhere compares
|
||||||
|
against dt.date.today() (also local). Mixing the two caused a real
|
||||||
|
bug: a ticket cancelled in the evening in a US timezone would get a
|
||||||
|
UTC timestamp that had already rolled into tomorrow, failing the
|
||||||
|
same-day check immediately and landing on Done instead of Cancelled.
|
||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
new_count = 0
|
new_count = 0
|
||||||
@@ -157,10 +164,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
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=(
|
fulfilled_at=(
|
||||||
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None
|
dt.datetime.now() if status_in(new_status, fulfilled_statuses) else None
|
||||||
),
|
),
|
||||||
cancelled_at=(
|
cancelled_at=(
|
||||||
dt.datetime.utcnow() if status_in(new_status, cancelled_statuses) else None
|
dt.datetime.now() if status_in(new_status, cancelled_statuses) else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -178,11 +185,11 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
if status_in(new_status, fulfilled_statuses) and not status_in(
|
if status_in(new_status, fulfilled_statuses) and not status_in(
|
||||||
old_status, fulfilled_statuses
|
old_status, fulfilled_statuses
|
||||||
):
|
):
|
||||||
existing.fulfilled_at = dt.datetime.utcnow()
|
existing.fulfilled_at = dt.datetime.now()
|
||||||
if status_in(new_status, cancelled_statuses) and not status_in(
|
if status_in(new_status, cancelled_statuses) and not status_in(
|
||||||
old_status, cancelled_statuses
|
old_status, cancelled_statuses
|
||||||
):
|
):
|
||||||
existing.cancelled_at = dt.datetime.utcnow()
|
existing.cancelled_at = dt.datetime.now()
|
||||||
|
|
||||||
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")
|
||||||
@@ -209,6 +216,29 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_local_database() -> int:
|
||||||
|
"""
|
||||||
|
Wipes every locally cached order. This is a rebuildable cache, not a
|
||||||
|
system of record - JIRA is - so this is always safe, just requires
|
||||||
|
re-running Import from JIRA (and Pull Tracking Numbers) afterward.
|
||||||
|
|
||||||
|
Also the real fix for one specific situation: a ticket that
|
||||||
|
transitioned status under an older, buggy version of this app can end
|
||||||
|
up with a permanently-wrong fulfilled_at/cancelled_at timestamp, since
|
||||||
|
those are only recalculated ON a transition - re-importing an
|
||||||
|
already-transitioned ticket finds "no change" and never touches it
|
||||||
|
again. A reset clears that stale timestamp entirely; the fresh
|
||||||
|
re-import then stamps everything correctly from scratch.
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
result = session.execute(delete(Order))
|
||||||
|
session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def load_all_orders() -> List[Order]:
|
def load_all_orders() -> List[Order]:
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -265,7 +295,7 @@ def mark_shipstation_sent(ticket_number: str) -> None:
|
|||||||
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
|
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if order is not None:
|
if order is not None:
|
||||||
order.shipstation_sent_at = dt.datetime.utcnow()
|
order.shipstation_sent_at = dt.datetime.now()
|
||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user