tune-dms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. tune_dms/__init__.py +25 -0
  2. tune_dms/config.py +57 -0
  3. tune_dms/images/confirm_save_as_dialog.png +0 -0
  4. tune_dms/images/excel_application_screen.png +0 -0
  5. tune_dms/images/save_as_window.png +0 -0
  6. tune_dms/images/tune_admin_purchase_orders_icon_selected.png +0 -0
  7. tune_dms/images/tune_application_screen.png +0 -0
  8. tune_dms/images/tune_cancel_button.png +0 -0
  9. tune_dms/images/tune_cancel_button_2.png +0 -0
  10. tune_dms/images/tune_cancel_button_c.png +0 -0
  11. tune_dms/images/tune_exit_button.png +0 -0
  12. tune_dms/images/tune_login_error.png +0 -0
  13. tune_dms/images/tune_login_screen.png +0 -0
  14. tune_dms/images/tune_login_screen_environment.png +0 -0
  15. tune_dms/images/tune_part_general_enquiry_cap_qty.png +0 -0
  16. tune_dms/images/tune_part_general_enquiry_dangerous_goods_class.png +0 -0
  17. tune_dms/images/tune_part_general_enquiry_default_supplier.png +0 -0
  18. tune_dms/images/tune_part_general_enquiry_other.png +0 -0
  19. tune_dms/images/tune_part_general_enquiry_pack_size.png +0 -0
  20. tune_dms/images/tune_part_general_enquiry_part.png +0 -0
  21. tune_dms/images/tune_part_general_enquiry_part_height.png +0 -0
  22. tune_dms/images/tune_part_general_enquiry_part_length.png +0 -0
  23. tune_dms/images/tune_part_general_enquiry_part_volume.png +0 -0
  24. tune_dms/images/tune_part_general_enquiry_part_weight.png +0 -0
  25. tune_dms/images/tune_part_general_enquiry_part_width.png +0 -0
  26. tune_dms/images/tune_part_general_enquiry_unit_of_measure.png +0 -0
  27. tune_dms/images/tune_previous_button.png +0 -0
  28. tune_dms/images/tune_report_parts_by_bin_location.png +0 -0
  29. tune_dms/images/tune_report_parts_price_list.png +0 -0
  30. tune_dms/images/tune_search_field.png +0 -0
  31. tune_dms/images/tune_select_franchise.png +0 -0
  32. tune_dms/images/tune_select_franchise_grey.png +0 -0
  33. tune_dms/images/tune_select_franchise_toy_grey.png +0 -0
  34. tune_dms/images/tune_select_franchise_toy_grey_box.png +0 -0
  35. tune_dms/images/tune_select_franchise_toy_yellow.png +0 -0
  36. tune_dms/images/tune_select_franchise_toy_yellow_box.png +0 -0
  37. tune_dms/images/tune_supplier_part_selection.png +0 -0
  38. tune_dms/images/tune_x_button.png +0 -0
  39. tune_dms/launcher.py +942 -0
  40. tune_dms-0.1.0.dist-info/METADATA +24 -0
  41. tune_dms-0.1.0.dist-info/RECORD +43 -0
  42. tune_dms-0.1.0.dist-info/WHEEL +5 -0
  43. tune_dms-0.1.0.dist-info/top_level.txt +1 -0
tune_dms/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ TUNE DMS: desktop GUI automation for login and report generation.
3
+ """
4
+
5
+ from tune_dms.config import TuneConfig
6
+ from tune_dms.launcher import (
7
+ main as run_tune_reports,
8
+ TuneReportGenerator,
9
+ PartsPriceListParams,
10
+ PartsByBinLocationParams,
11
+ parts_price_list_report_download,
12
+ parts_by_bin_location_report_download,
13
+ )
14
+
15
+ __all__ = [
16
+ "TuneConfig",
17
+ "run_tune_reports",
18
+ "TuneReportGenerator",
19
+ "PartsPriceListParams",
20
+ "PartsByBinLocationParams",
21
+ "parts_price_list_report_download",
22
+ "parts_by_bin_location_report_download",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
tune_dms/config.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ Configuration for TUNE DMS (desktop GUI automation).
3
+ Users can set settings via environment variables or pass config programmatically.
4
+ """
5
+
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+
11
+ def _load_dotenv_if_available() -> None:
12
+ try:
13
+ from dotenv import load_dotenv
14
+ load_dotenv()
15
+ except ImportError:
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TuneConfig:
21
+ """Configuration for TUNE DMS (desktop GUI automation)."""
22
+
23
+ user_id: str
24
+ password: str
25
+ shortcut_path: str = r"C:\Users\Public\Desktop\TUNE.lnk"
26
+ images_dir: Optional[str] = None
27
+ reports_dir: Optional[str] = None
28
+
29
+ @classmethod
30
+ def from_env(
31
+ cls,
32
+ *,
33
+ user_id: Optional[str] = None,
34
+ password: Optional[str] = None,
35
+ shortcut_path: Optional[str] = None,
36
+ images_dir: Optional[str] = None,
37
+ reports_dir: Optional[str] = None,
38
+ load_dotenv: bool = True,
39
+ ) -> "TuneConfig":
40
+ """Build config from environment variables. Override any field by passing it explicitly."""
41
+ if load_dotenv:
42
+ _load_dotenv_if_available()
43
+ return cls(
44
+ user_id=user_id or os.getenv("TUNE_USER_ID") or "",
45
+ password=password or os.getenv("TUNE_USER_PASSWORD") or "",
46
+ shortcut_path=shortcut_path or os.getenv("TUNE_SHORTCUT_PATH") or cls.shortcut_path,
47
+ images_dir=images_dir or os.getenv("TUNE_IMAGES_DIR"),
48
+ reports_dir=reports_dir or os.getenv("TUNE_REPORTS_DIR"),
49
+ )
50
+
51
+ def validate(self) -> None:
52
+ """Raise ValueError if required fields are missing."""
53
+ if not self.user_id or not self.password:
54
+ raise ValueError(
55
+ "TUNE_USER_ID and TUNE_USER_PASSWORD must be set "
56
+ "(via TuneConfig.from_env(), environment variables, or constructor)."
57
+ )
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
tune_dms/launcher.py ADDED
@@ -0,0 +1,942 @@
1
+ import subprocess
2
+ import os
3
+ import time
4
+ import pyautogui
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import Optional, Literal
8
+ import traceback
9
+ import sys
10
+
11
+ from tune_dms.config import TuneConfig
12
+
13
+ # Configure logging
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format='%(asctime)s - %(levelname)s - %(message)s',
17
+ datefmt='%Y-%m-%d %H:%M:%S'
18
+ )
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Set by main() so that login_to_tune, launch_tune_application, and waitFor use it
22
+ _config: Optional[TuneConfig] = None
23
+
24
+
25
+ def _get_images_dir() -> str:
26
+ """Return the images directory from config or package default (tune_dms/images/)."""
27
+ if _config and _config.images_dir:
28
+ return _config.images_dir
29
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
30
+ return os.path.join(pkg_dir, "images")
31
+
32
+ # Define dataclasses for report parameters
33
+ @dataclass
34
+ class PartsPriceListParams:
35
+ from_department: str
36
+ from_franchise: str
37
+ to_franchise: str = None # Default to same as from_franchise if None
38
+ from_bin: Optional[str] = None
39
+ to_bin: Optional[str] = None
40
+ price_1: Literal["List"] = "List"
41
+ price_2: Literal["Stock"] = "Stock"
42
+ include_gst: bool = True
43
+ output_file_type: Literal["CSV", "Excel"] = "CSV"
44
+ output_file_name: str = None # Will be set in the function
45
+
46
+ @dataclass
47
+ class PartsByBinLocationParams:
48
+ from_department: str
49
+ to_department: Optional[str] = None # Default to same as from_department if None
50
+ from_franchise: str = "TOY"
51
+ to_franchise: Optional[str] = None # Default to same as from_franchise if None
52
+ from_bin: Optional[str] = None
53
+ to_bin: Optional[str] = None
54
+ from_movement_code: Optional[str] = None
55
+ to_movement_code: Optional[str] = None
56
+ show_stock_as: Literal["Physical Stock", "Available Stock"] = "Physical Stock"
57
+ print_when_stock_not_zero: bool = True
58
+ print_part_when_stock_is_zero: bool = False
59
+ print_part_when_stock_on_order_is_zero: bool = False
60
+ no_primary_bin_location: bool = False
61
+ no_alternate_bin_location: bool = False
62
+ no_primary_bin_but_has_alternate_bin: bool = False
63
+ has_both_primary_and_alternate_bin_location: bool = False
64
+ last_sale_before: Optional[str] = None
65
+ last_receipt_before: Optional[str] = None
66
+ print_average_cost: bool = False
67
+ report_format: Literal["Split departments onto new page", "Print all departments on one page"] = "Split departments onto new page"
68
+ output_file_type: Literal["CSV", "Excel"] = "CSV"
69
+ output_file_name: str = None # Will be set in the function
70
+
71
+ def waitFor(image_name, timeout=10, confidence=0.9):
72
+ """
73
+ Wait for an image to appear on screen.
74
+
75
+ Args:
76
+ image_name (str): Name of the image file in the images directory
77
+ timeout (int): Maximum time to wait in seconds
78
+ confidence (float): Confidence level for image matching (0-1)
79
+
80
+ Returns:
81
+ tuple or None: Position of the image if found, None otherwise
82
+ """
83
+ image_path = os.path.join(_get_images_dir(), image_name)
84
+ logger.info(f"Waiting for image: {image_path}")
85
+
86
+ if not os.path.exists(image_path):
87
+ logger.error(f"Image file not found: {image_path}")
88
+ return None
89
+
90
+ start_time = time.time()
91
+
92
+ while time.time() - start_time < timeout:
93
+ try:
94
+ position = pyautogui.locateOnScreen(image_path, confidence=confidence)
95
+ if position:
96
+ logger.info(f"Found image at position: {position}")
97
+ return position
98
+ except pyautogui.ImageNotFoundException:
99
+ pass
100
+ except Exception as e:
101
+ logger.error(f"Error finding image: {e}")
102
+ return None
103
+
104
+ time.sleep(0.5)
105
+
106
+ logger.warning(f"Image not found after {timeout} seconds: {image_path}")
107
+ return None
108
+
109
+ def open_parts_price_list_report(currently_selected_report: str = None):
110
+ """
111
+ Navigate to and open the Parts Price List Report in TUNE
112
+ by using keyboard navigation
113
+
114
+ Returns:
115
+ bool: True if the operation was attempted, False otherwise
116
+ """
117
+ try:
118
+ logger.info("Attempting to open Parts Price List Report...")
119
+
120
+ if currently_selected_report == 'Parts By Bin Location':
121
+ for i in range(2):
122
+ pyautogui.press('up')
123
+ # Press Enter to select the menu item
124
+ pyautogui.press('enter')
125
+ logger.info("Opening the report configuration dialog")
126
+ return True
127
+
128
+
129
+ # Press Down arrow key 6 times to Parts
130
+ for i in range(6):
131
+ pyautogui.press('down')
132
+
133
+ # Open Parts folder
134
+ pyautogui.press('right')
135
+
136
+ # Press Down arrow key 1 time to Parts
137
+ for i in range(5):
138
+ pyautogui.press('down')
139
+
140
+ # Open reports folder
141
+ pyautogui.press('right')
142
+
143
+ # Open Parts Price List Report
144
+ for i in range(16):
145
+ pyautogui.press('down')
146
+
147
+ # Press Enter to select the menu item
148
+ pyautogui.press('enter')
149
+ logger.info("Opening the report configuration dialog")
150
+
151
+ # Wait for the report configuration dialog to open
152
+ report_configuration_dialog = waitFor('tune_report_parts_price_list.png')
153
+ if not report_configuration_dialog:
154
+ logger.error("Report configuration dialog not found")
155
+ return False
156
+
157
+ logger.info("Parts Price Pist Peport navigation sequence executed successfully")
158
+ return True
159
+ except Exception as e:
160
+ logger.error(f"Error while opening Parts Price List Report: {e}")
161
+ return False
162
+
163
+ def parts_price_list_report_download(params: PartsPriceListParams = None, reports_dir: str = None, **kwargs):
164
+ """
165
+ Download the Parts Price List Report with user-friendly parameter options.
166
+
167
+ Args:
168
+ params: A PartsPriceListParams object containing all parameters
169
+ reports_dir: Directory where reports will be saved
170
+ **kwargs: Individual parameters that override those in params (if provided)
171
+
172
+ Returns:
173
+ bool: True if successful, False otherwise
174
+ """
175
+ try:
176
+ # Create reports directory if it doesn't exist
177
+ if reports_dir:
178
+ os.makedirs(reports_dir, exist_ok=True)
179
+
180
+ # Allow creating params from kwargs or updating the provided params
181
+ if params is None:
182
+ params = PartsPriceListParams(**kwargs)
183
+ elif kwargs:
184
+ # Update params with any provided kwargs
185
+ for key, value in kwargs.items():
186
+ if hasattr(params, key):
187
+ setattr(params, key, value)
188
+
189
+ # Set output file name if not provided
190
+ if params.output_file_name is None and reports_dir:
191
+ params.output_file_name = "parts_price_list_report.csv"
192
+
193
+ # Set output file path
194
+ params.output_file_path = os.path.join(reports_dir, params.output_file_name)
195
+
196
+ # Auto-populate to_franchise if not provided
197
+ if params.to_franchise is None:
198
+ params.to_franchise = params.from_franchise
199
+
200
+ logger.info(f"Downloading Parts Price List Report with parameters: {params}")
201
+
202
+ # Start navigating the form
203
+ pyautogui.press('tab')
204
+ pyautogui.write(params.from_department)
205
+
206
+ pyautogui.press('tab')
207
+ pyautogui.write(params.from_franchise)
208
+
209
+ pyautogui.press('tab')
210
+ pyautogui.write(params.to_franchise)
211
+
212
+ pyautogui.press('tab')
213
+ if params.from_bin:
214
+ pyautogui.write(params.from_bin)
215
+
216
+ pyautogui.press('tab')
217
+ if params.to_bin:
218
+ pyautogui.write(params.to_bin)
219
+
220
+ pyautogui.press('tab')
221
+ if params.price_1 == 'List':
222
+ for i in range(2):
223
+ pyautogui.press('down')
224
+ else:
225
+ logger.error(f"Invalid price type: {params.price_1}")
226
+
227
+ pyautogui.press('tab')
228
+ if params.price_2 == 'Stock':
229
+ for i in range(25):
230
+ pyautogui.press('down')
231
+ pyautogui.press('enter')
232
+ else:
233
+ logger.error(f"Invalid price type: {params.price_2}")
234
+
235
+ pyautogui.press('tab')
236
+ if params.include_gst:
237
+ pyautogui.press('space')
238
+ pyautogui.press('tab')
239
+ if params.include_gst:
240
+ pyautogui.press('space')
241
+
242
+ pyautogui.press('o')
243
+
244
+ if params.output_file_type == 'CSV':
245
+ for i in range(4):
246
+ pyautogui.press('down')
247
+ elif params.output_file_type == 'Excel':
248
+ for i in range(5):
249
+ pyautogui.press('down')
250
+ else:
251
+ logger.error(f"Invalid output file type: {params.output_file_type}")
252
+
253
+ time.sleep(0.1)
254
+
255
+ pyautogui.press('o')
256
+ save_as_window = waitFor('save_as_window.png', timeout=15)
257
+ if not save_as_window:
258
+ logger.error("Save as window not found")
259
+ return False
260
+ pyautogui.write(params.output_file_path)
261
+ pyautogui.press('enter')
262
+
263
+ # Overwrite the file if it already exists
264
+ confirm_save_as_dialog = waitFor('confirm_save_as_dialog.png')
265
+ if confirm_save_as_dialog:
266
+ pyautogui.press('left')
267
+ pyautogui.press('enter')
268
+
269
+ # Start timing the report generation
270
+ start_time = time.time()
271
+ logger.info("Starting report generation...")
272
+ # Wait for report to finish
273
+ report_success = waitFor('tune_application_screen.png', timeout=80)
274
+ # Calculate and log the duration
275
+ end_time = time.time()
276
+ duration = round(end_time - start_time, 2)
277
+ if not report_success:
278
+ logger.error(f"Report FAILED after {duration} seconds")
279
+ else:
280
+ logger.info(f"Report generation completed in {duration} seconds")
281
+
282
+ # Verify the file exists and has content
283
+ if not os.path.exists(params.output_file_path):
284
+ logger.error(f"Report file was not created: {params.output_file_path}")
285
+ return False
286
+
287
+ # Wait a moment for file to be fully written
288
+ time.sleep(1)
289
+
290
+ # Check file size
291
+ file_size = os.path.getsize(params.output_file_path)
292
+ if file_size == 0:
293
+ logger.error(f"Report file is empty: {params.output_file_path}")
294
+ return False
295
+
296
+ logger.info(f"Parts Price List Report downloaded successfully (size: {file_size} bytes)")
297
+
298
+ '''
299
+ # Check if user clicked and report opened
300
+ report_open = waitFor('tune_admin_purchase_orders_icon_selected.png', timeout=15)
301
+ if report_open:
302
+ logger.info("Report is open, closing it now")
303
+ # Find and click on the "Previous" button to close the report
304
+ previous_button = waitFor('tune_work_with_purchase_orders.png')
305
+ if previous_button:
306
+ pyautogui.click(pyautogui.center(previous_button))
307
+ logger.info("Clicked on Previous button")
308
+ time.sleep(0.5)
309
+ logger.info("Report closed")
310
+ '''
311
+
312
+ return True
313
+ except Exception as e:
314
+ logger.error(f"Error while downloading Parts Price List Report: {e}")
315
+ return False
316
+
317
+ def open_parts_by_bin_location_report():
318
+ """
319
+ Navigate to and open the Parts by Bin Location Report in TUNE
320
+ """
321
+ try:
322
+ logger.info("Attempting to open Parts by Bin Location Report...")
323
+
324
+ # Press Down arrow key 2 times to Parts
325
+ for i in range(2):
326
+ pyautogui.press('down')
327
+
328
+ # Press Enter to select the menu item
329
+ pyautogui.press('enter')
330
+
331
+ # Wait for the report configuration dialog to open
332
+ report_configuration_dialog = waitFor('tune_report_parts_by_bin_location.png')
333
+ if not report_configuration_dialog:
334
+ logger.error("Report configuration dialog not found")
335
+ return False
336
+
337
+ logger.info("Parts by Bin Location Report opened successfully")
338
+ return True
339
+ except Exception as e:
340
+ logger.error(f"Error while opening Parts by Bin Location Report: {e}")
341
+ return False
342
+
343
+ def parts_by_bin_location_report_download(params: PartsByBinLocationParams = None, reports_dir: str = None, **kwargs):
344
+ """
345
+ Download the Parts by Bin Location Report with user-friendly parameter options.
346
+
347
+ Args:
348
+ params: A PartsByBinLocationParams object containing all parameters
349
+ reports_dir: Directory where reports will be saved
350
+ **kwargs: Individual parameters that override those in params (if provided)
351
+
352
+ Returns:
353
+ bool: True if successful, False otherwise
354
+ """
355
+ try:
356
+ # Create reports directory if it doesn't exist
357
+ if reports_dir:
358
+ os.makedirs(reports_dir, exist_ok=True)
359
+
360
+ # Allow creating params from kwargs or updating the provided params
361
+ if params is None:
362
+ params = PartsByBinLocationParams(**kwargs)
363
+ elif kwargs:
364
+ # Update params with any provided kwargs
365
+ for key, value in kwargs.items():
366
+ if hasattr(params, key):
367
+ setattr(params, key, value)
368
+
369
+ # Set output file name if not provided
370
+ if params.output_file_name is None and reports_dir:
371
+ params.output_file_name = "parts_by_bin_location_report.csv"
372
+
373
+ # Set output file path
374
+ params.output_file_path = os.path.join(reports_dir, params.output_file_name)
375
+
376
+ # Auto-populate defaults if not provided
377
+ if params.to_department is None:
378
+ params.to_department = params.from_department
379
+
380
+ if params.to_franchise is None:
381
+ params.to_franchise = params.from_franchise
382
+
383
+ logger.info(f"Downloading Parts by Bin Location Report with parameters: {params}")
384
+
385
+ # Tab to Department
386
+ pyautogui.write(params.from_department)
387
+
388
+ # Tab to To Department
389
+ pyautogui.press('tab')
390
+ pyautogui.write(params.to_department)
391
+
392
+ # Tab to Franchise
393
+ pyautogui.press('tab')
394
+ pyautogui.write(params.from_franchise)
395
+
396
+ # Tab to To Franchise
397
+ pyautogui.press('tab')
398
+ pyautogui.write(params.to_franchise)
399
+
400
+ # Tab to From Bin
401
+ pyautogui.press('tab')
402
+ if params.from_bin:
403
+ pyautogui.write(params.from_bin)
404
+
405
+ # Tab to To Bin
406
+ pyautogui.press('tab')
407
+ if params.to_bin:
408
+ pyautogui.write(params.to_bin)
409
+
410
+ # Tab to From Movement Code
411
+ pyautogui.press('tab')
412
+ if params.from_movement_code:
413
+ pyautogui.write(params.from_movement_code)
414
+
415
+ # Tab to To Movement Code
416
+ pyautogui.press('tab')
417
+ if params.to_movement_code:
418
+ pyautogui.write(params.to_movement_code)
419
+
420
+ # Tab to Show Stock As
421
+ pyautogui.press('tab')
422
+ if params.show_stock_as == "Physical Stock":
423
+ logger.info("Physical Stock selected")
424
+ elif params.show_stock_as == "Available Stock":
425
+ pyautogui.press('down')
426
+ else:
427
+ logger.error(f"Invalid show stock as option: {params.show_stock_as}")
428
+
429
+ # Tab to Print When Stock Not Zero
430
+ pyautogui.press('tab')
431
+ if params.print_when_stock_not_zero:
432
+ logger.info("Print when stock not zero checked")
433
+
434
+ # Tab to Print part when stock is Zero
435
+ pyautogui.press('tab')
436
+ if params.print_part_when_stock_is_zero:
437
+ logger.info("Print part when stock is Zero checked")
438
+
439
+ # Tab to Print part when STOCK ON ORDER is ZERO
440
+ pyautogui.press('tab')
441
+ if params.print_part_when_stock_on_order_is_zero:
442
+ logger.info("Print part when stock on order is Zero checked")
443
+
444
+ # Tab to NO PRIMARY bin location
445
+ pyautogui.press('tab')
446
+ if params.no_primary_bin_location:
447
+ logger.info("No primary bin location checked")
448
+
449
+ # Tab to NO ALTERNATE bin location
450
+ pyautogui.press('tab')
451
+ if params.no_alternate_bin_location:
452
+ logger.info("No alternate bin location checked")
453
+
454
+ # Tab to NO PRIMARY bin, but has an ALTERNATE bin location
455
+ pyautogui.press('tab')
456
+ if params.no_primary_bin_but_has_alternate_bin:
457
+ logger.info("No primary bin, but has an alternate bin checked")
458
+
459
+ # Tab to has BOTH a PRIMARY and ALTERNATE bin location
460
+ pyautogui.press('tab')
461
+ if params.has_both_primary_and_alternate_bin_location:
462
+ logger.info("Has both a primary and alternate bin location checked")
463
+
464
+ # Tab to Last Sale Before (Date)
465
+ pyautogui.press('tab')
466
+ if params.last_sale_before:
467
+ logger.info("Last sale before checked")
468
+
469
+ # Tab to Last Receipt Before Date
470
+ pyautogui.press('tab')
471
+ if params.last_receipt_before:
472
+ logger.info("Last receipt before checked")
473
+
474
+ # Tab to Print Average Cost
475
+ pyautogui.press('tab')
476
+ if params.print_average_cost:
477
+ pyautogui.press('space')
478
+ logger.info("Print average cost checked")
479
+
480
+ # Tab to Report format
481
+ #pyautogui.press('tab')
482
+ #if params.report_format == 'Split departments onto new page':
483
+ # logger.info("Split departments onto new page")
484
+ #elif params.report_format == 'Print all departments on one page':
485
+ # logger.info("Print all departments on one page")
486
+ #else:
487
+ # logger.error(f"Invalid report format: {params.report_format}")
488
+
489
+ # Tab to OK
490
+ #pyautogui.press('tab')
491
+ #pyautogui.press('enter')
492
+ pyautogui.press('o')
493
+ time.sleep(0.1)
494
+ # Select output type
495
+ if params.output_file_type == 'CSV':
496
+ for i in range(4):
497
+ pyautogui.press('down')
498
+ elif params.output_file_type == 'Excel':
499
+ for i in range(5):
500
+ pyautogui.press('down')
501
+ else:
502
+ logger.error(f"Invalid output file type: {params.output_file_type}")
503
+
504
+ # Select Output
505
+ pyautogui.press('o')
506
+ save_as_window = waitFor('save_as_window.png', timeout=15)
507
+ if not save_as_window:
508
+ logger.error("Save as window not found")
509
+ return False
510
+ pyautogui.write(params.output_file_path)
511
+ pyautogui.press('enter')
512
+
513
+ # Overwrite the file if it already exists
514
+ confirm_save_as_dialog = waitFor('confirm_save_as_dialog.png')
515
+ if confirm_save_as_dialog:
516
+ pyautogui.press('left')
517
+ pyautogui.press('enter')
518
+
519
+ # Start timing the report generation
520
+ start_time = time.time()
521
+ logger.info("Starting report generation...")
522
+ # Wait for report to finish
523
+ report_success = waitFor('tune_application_screen.png', timeout=60)
524
+ # Calculate and log the duration
525
+ end_time = time.time()
526
+ duration = round(end_time - start_time, 2)
527
+ if not report_success:
528
+ logger.error(f"Report generation failed or timed out after {duration} seconds")
529
+ return False
530
+ else:
531
+ logger.info(f"Report generation completed in {duration} seconds")
532
+
533
+ # Verify the file exists and has content
534
+ if not os.path.exists(params.output_file_path):
535
+ logger.error(f"Report file was not created: {params.output_file_path}")
536
+ return False
537
+
538
+ # Wait a moment for file to be fully written
539
+ time.sleep(1)
540
+
541
+ # Check file size
542
+ file_size = os.path.getsize(params.output_file_path)
543
+ if file_size == 0:
544
+ logger.error(f"Report file is empty: {params.output_file_path}")
545
+ return False
546
+
547
+ logger.info(f"Parts by Bin Location Report downloaded successfully (size: {file_size} bytes)")
548
+ return True
549
+ except Exception as e:
550
+ logger.error(f"Error while downloading Parts by Bin Location Report: {e}")
551
+ return False
552
+
553
+ def close_tune_application():
554
+ """
555
+ Close TUNE application using Alt, Down, E keyboard shortcut
556
+
557
+ Returns:
558
+ bool: True if the operation was attempted, False otherwise
559
+ """
560
+ try:
561
+ logger.info("Attempting to close TUNE application...")
562
+
563
+ # Press Alt key
564
+ pyautogui.press('alt')
565
+ time.sleep(0.3)
566
+
567
+ # Press Down arrow key
568
+ pyautogui.press('down')
569
+ time.sleep(0.3)
570
+
571
+ # Press E key
572
+ pyautogui.press('e')
573
+
574
+ logger.info("TUNE close sequence executed successfully")
575
+ return True
576
+ except Exception as e:
577
+ logger.error(f"Error while closing TUNE: {e}")
578
+ return False
579
+
580
+ def launch_tune_application():
581
+ """Launch the TUNE software application using the shortcut (path from config)."""
582
+ global _config
583
+ if not _config:
584
+ raise RuntimeError("TUNE config not set. Call run_tune_reports(config) first.")
585
+ logger.info("Launching TUNE software using shortcut...")
586
+ shortcut_path = _config.shortcut_path
587
+ try:
588
+ if not os.path.exists(shortcut_path):
589
+ logger.error(f"TUNE shortcut not found at {shortcut_path}")
590
+ raise FileNotFoundError(f"TUNE shortcut not found at {shortcut_path}")
591
+
592
+ # Start the TUNE process using the shortcut
593
+ process = subprocess.Popen(f'start "" "{shortcut_path}"', shell=True)
594
+ logger.info("TUNE process started using shortcut")
595
+ return process
596
+ except Exception as e:
597
+ logger.error(f"Failed to launch TUNE: {e}")
598
+ raise
599
+
600
+ def login_to_tune():
601
+ """Login to TUNE using credentials from config (set by run_tune_reports)."""
602
+ global _config
603
+ if not _config:
604
+ raise RuntimeError("TUNE config not set. Call run_tune_reports(config) first.")
605
+ logger.info("Attempting to login to TUNE...")
606
+ login_screen = waitFor('tune_login_screen.png')
607
+ if not login_screen:
608
+ logger.error("Login screen not found")
609
+ return False
610
+ pyautogui.click(pyautogui.center(login_screen))
611
+ time.sleep(0.5)
612
+ if waitFor('tune_login_screen_environment.png', timeout=1):
613
+ logger.info("TUNE Environment not selected, selecting")
614
+ time.sleep(0.2)
615
+ pyautogui.press('e')
616
+ time.sleep(0.2)
617
+ pyautogui.press('down')
618
+ time.sleep(0.2)
619
+ pyautogui.press('tab')
620
+ time.sleep(0.5)
621
+ pyautogui.write(_config.user_id)
622
+ logger.info("Entered User ID")
623
+ time.sleep(0.5)
624
+ pyautogui.press('tab')
625
+ time.sleep(0.5)
626
+ pyautogui.write(_config.password)
627
+ logger.info("Entered Password")
628
+ time.sleep(0.5)
629
+
630
+ # Press Enter to submit login
631
+ pyautogui.press('enter')
632
+ logger.info("Submitted login form")
633
+
634
+ # Check if login failed
635
+ login_error = waitFor('tune_login_error.png', timeout=3)
636
+ if login_error:
637
+ logger.error("Login failed")
638
+ time.sleep(2)
639
+ pyautogui.press('space')
640
+ return False
641
+
642
+ # Wait for the application screen to appear
643
+ app_screen = waitFor('tune_application_screen.png', timeout=30) # Longer timeout for application loading
644
+ if not app_screen:
645
+ logger.error("Application screen not found after login")
646
+ return False
647
+
648
+ logger.info("Successfully logged into TUNE")
649
+ return True
650
+
651
+ class TuneReportGenerator:
652
+ """Generates reports from the TUNE system."""
653
+
654
+ def __init__(self, config: TuneConfig):
655
+ self.config = config
656
+
657
+ def run_reports(self) -> bool:
658
+ """Run the TUNE launcher to download the required reports."""
659
+ logger.info("Starting TUNE report generation...")
660
+ reports_dir = self.config.reports_dir or os.getcwd()
661
+ try:
662
+ # Launch TUNE
663
+ launch_tune_application()
664
+ time.sleep(3)
665
+ # Login to TUNE
666
+ login_success = login_to_tune()
667
+ if not login_success:
668
+ logger.error("Failed to login to TUNE. Exiting.")
669
+ return False
670
+ time.sleep(2)
671
+ # Select 130 Department - MCT Parts and Accessories
672
+ for i in range(3):
673
+ pyautogui.press('tab')
674
+ time.sleep(0.1)
675
+ for i in range(6):
676
+ pyautogui.press('up')
677
+ time.sleep(0.1)
678
+
679
+ # Move to menu
680
+ for i in range(5):
681
+ pyautogui.press('tab')
682
+
683
+ # Generate Parts Price List Report
684
+ logger.info("Generating Parts Price List Report...")
685
+ report_success = open_parts_price_list_report()
686
+ if report_success:
687
+ # Configure report parameters
688
+ params = PartsPriceListParams(
689
+ from_department='130',
690
+ from_franchise='TOY',
691
+ output_file_name='Parts Price List Report - 130.csv'
692
+ )
693
+ # Download the report
694
+ download_success = parts_price_list_report_download(params, reports_dir=reports_dir)
695
+ if not download_success:
696
+ logger.error("Failed to download Parts Price List Report")
697
+ close_tune_application()
698
+ return False
699
+ logger.info("Parts Price List Report generated successfully")
700
+ else:
701
+ logger.error("Failed to open Parts Price List Report")
702
+ close_tune_application()
703
+ return False
704
+
705
+ # Generate Parts by Bin Location Report
706
+ logger.info("Generating Parts by Bin Location Report...")
707
+ report_success = open_parts_by_bin_location_report()
708
+ if report_success:
709
+ # Configure report parameters
710
+ params = PartsByBinLocationParams(
711
+ from_department='130',
712
+ from_franchise='TOY',
713
+ show_stock_as='Available Stock',
714
+ print_when_stock_not_zero=True,
715
+ output_file_name='Parts by Bin Location Report - 130.csv'
716
+ )
717
+ # Download the report
718
+ download_success = parts_by_bin_location_report_download(params, reports_dir=reports_dir)
719
+ if not download_success:
720
+ logger.error("Failed to download Parts by Bin Location Report")
721
+ close_tune_application()
722
+ return False
723
+ logger.info("Parts by Bin Location Report generated successfully")
724
+ else:
725
+ logger.error("Failed to open Parts by Bin Location Report")
726
+ close_tune_application()
727
+ return False
728
+
729
+ # Select 145 Department - MCT Tyres
730
+ for i in range(5): # Go to department menu
731
+ pyautogui.hotkey('shift', 'tab')
732
+ time.sleep(0.1)
733
+ for i in range(3): # Select 145
734
+ pyautogui.press('down')
735
+ time.sleep(0.1)
736
+
737
+ # Move to menu
738
+ for i in range(5):
739
+ pyautogui.press('tab')
740
+
741
+ # Generate Tyres Price List Report
742
+ logger.info("Generating Tyres Price List Report...")
743
+ report_success = open_parts_price_list_report(currently_selected_report='Parts By Bin Location')
744
+ if report_success:
745
+ # Configure report parameters
746
+ params = PartsPriceListParams(
747
+ from_department='145',
748
+ from_franchise='TOY',
749
+ output_file_name='Parts Price List Report - 145.csv'
750
+ )
751
+ # Download the report
752
+ download_success = parts_price_list_report_download(params, reports_dir=reports_dir)
753
+ if not download_success:
754
+ logger.error("Failed to download Tyres Price List Report")
755
+ close_tune_application()
756
+ return False
757
+ logger.info("Tyres Price List Report generated successfully")
758
+ else:
759
+ logger.error("Failed to open Tyres Price List Report")
760
+ close_tune_application()
761
+ return False
762
+
763
+ # Generate Tyres by Bin Location Report
764
+ logger.info("Generating Tyres by Bin Location Report...")
765
+ report_success = open_parts_by_bin_location_report()
766
+ if report_success:
767
+ # Configure report parameters
768
+ params = PartsByBinLocationParams(
769
+ from_department='145',
770
+ from_franchise='TOY',
771
+ output_file_name='Parts by Bin Location Report - 145.csv'
772
+ )
773
+ # Download the report
774
+ download_success = parts_by_bin_location_report_download(params, reports_dir=reports_dir)
775
+ if not download_success:
776
+ logger.error("Failed to download Tyres by Bin Location Report")
777
+ close_tune_application()
778
+ return False
779
+ logger.info("Tyres by Bin Location Report generated successfully")
780
+ else:
781
+ logger.error("Failed to open Tyres by Bin Location Report")
782
+ close_tune_application()
783
+ return False
784
+
785
+ # Select 330 Department - Ingham Toyota
786
+ for i in range(5): # Go to department menu
787
+ pyautogui.hotkey('shift', 'tab')
788
+ time.sleep(0.1)
789
+ for i in range(7): # Select 330
790
+ pyautogui.press('down')
791
+ time.sleep(0.1)
792
+
793
+ # Move to menu
794
+ for i in range(5):
795
+ pyautogui.press('tab')
796
+
797
+ # Generate Ingham Toyota Parts Price List Report
798
+ logger.info("Generating Ingham Toyota Parts Price List Report...")
799
+ report_success = open_parts_price_list_report(currently_selected_report='Parts By Bin Location')
800
+ if report_success:
801
+ # Configure report parameters
802
+ params = PartsPriceListParams(
803
+ from_department='330',
804
+ from_franchise='TOY',
805
+ output_file_name='Parts Price List Report - 330.csv'
806
+ )
807
+ # Download the report
808
+ download_success = parts_price_list_report_download(params, reports_dir=reports_dir)
809
+ if not download_success:
810
+ logger.error("Failed to download Ingham Toyota Parts Price List Report")
811
+ close_tune_application()
812
+ return False
813
+ logger.info("Ingham Toyota Parts Price List Report generated successfully")
814
+ else:
815
+ logger.error("Failed to open Ingham Toyota Parts Price List Report")
816
+ close_tune_application()
817
+ return False
818
+
819
+ # Generate Ingham Toyota Parts by Bin Location Report
820
+ logger.info("Generating Ingham Toyota Parts by Bin Location Report...")
821
+ report_success = open_parts_by_bin_location_report()
822
+ if report_success:
823
+ # Configure report parameters
824
+ params = PartsByBinLocationParams(
825
+ from_department='330',
826
+ from_franchise='TOY',
827
+ output_file_name='Parts by Bin Location Report - 330.csv'
828
+ )
829
+ # Download the report
830
+ download_success = parts_by_bin_location_report_download(params, reports_dir=reports_dir)
831
+ if not download_success:
832
+ logger.error("Failed to download Ingham Toyota Parts by Bin Location Report")
833
+ close_tune_application()
834
+ return False
835
+ logger.info("Ingham Toyota Parts by Bin Location Report generated successfully")
836
+ else:
837
+ logger.error("Failed to open Ingham Toyota Parts by Bin Location Report")
838
+ close_tune_application()
839
+
840
+ # Select Company 04 - Charters Towers Partnership
841
+ for i in range(7): # Go to company menu
842
+ pyautogui.hotkey('shift', 'tab')
843
+ pyautogui.press('down')
844
+ logger.info("Selected Company 04 - Charters Towers Partnership")
845
+ time.sleep(2) # Wait for 'Updating' to finish
846
+ # Department 430 - Parts & Acc selected by default
847
+
848
+ # Move to menu
849
+ for i in range(7):
850
+ pyautogui.press('tab')
851
+
852
+ # Generate Charters Towers Partnership Parts Price List Report
853
+ logger.info("Generating Charters Towers Partnership Parts Price List Report...")
854
+ report_success = open_parts_price_list_report()
855
+ if report_success:
856
+ # Configure report parameters
857
+ params = PartsPriceListParams(
858
+ from_department='430',
859
+ from_franchise='TOY',
860
+ output_file_name='Parts Price List Report - 430.csv'
861
+ )
862
+ # Download the report
863
+ download_success = parts_price_list_report_download(params, reports_dir=reports_dir)
864
+ if not download_success:
865
+ logger.error("Failed to download Charters Towers Partnership Parts Price List Report")
866
+ close_tune_application()
867
+ return False
868
+ logger.info("Charters Towers Partnership Parts Price List Report generated successfully")
869
+ else:
870
+ logger.error("Failed to open Charters Towers Partnership Parts Price List Report")
871
+ close_tune_application()
872
+ return False
873
+
874
+ # Generate Charters Towers Partnership Parts by Bin Location Report
875
+
876
+ logger.info("Generating Charters Towers Partnership Parts by Bin Location Report...")
877
+ report_success = open_parts_by_bin_location_report()
878
+ if report_success:
879
+ # Configure report parameters
880
+ params = PartsByBinLocationParams(
881
+ from_department='430',
882
+ from_franchise='TOY',
883
+ output_file_name='Parts by Bin Location Report - 430.csv'
884
+ )
885
+ # Download the report
886
+ download_success = parts_by_bin_location_report_download(params, reports_dir=reports_dir)
887
+ if not download_success:
888
+ logger.error("Failed to download Charters Towers Partnership Parts by Bin Location Report")
889
+ close_tune_application()
890
+ return False
891
+ logger.info("Charters Towers Partnership Parts by Bin Location Report generated successfully")
892
+ else:
893
+ logger.error("Failed to open Charters Towers Partnership Parts by Bin Location Report")
894
+ close_tune_application()
895
+ return False
896
+
897
+ # Close TUNE
898
+ close_tune_application()
899
+ logger.info("TUNE reports generation completed successfully")
900
+ return True
901
+
902
+ except Exception as e:
903
+ logger.error(f"Error during TUNE report generation: {e}")
904
+ traceback.print_exc()
905
+ # Try to close TUNE if it's open
906
+ try:
907
+ close_tune_application()
908
+ except:
909
+ pass
910
+ return False
911
+
912
+ def main(config: TuneConfig) -> bool:
913
+ """Main entry point: run TUNE report generation with the given config."""
914
+ global _config
915
+ _config = config
916
+ try:
917
+ config.validate()
918
+ images_dir = config.images_dir or _get_images_dir()
919
+ login_image = os.path.join(images_dir, 'tune_login_screen.png')
920
+ app_image = os.path.join(images_dir, 'tune_application_screen.png')
921
+ missing_images = []
922
+ if not os.path.exists(login_image):
923
+ missing_images.append('tune_login_screen.png')
924
+ if not os.path.exists(app_image):
925
+ missing_images.append('tune_application_screen.png')
926
+ if missing_images:
927
+ missing_str = ', '.join(missing_images)
928
+ logger.error(f"Missing reference images: {missing_str}")
929
+ return False
930
+ report_generator = TuneReportGenerator(config)
931
+ return report_generator.run_reports()
932
+ except Exception as e:
933
+ logger.error(f"An error occurred: {e}")
934
+ return False
935
+ finally:
936
+ _config = None
937
+
938
+
939
+ if __name__ == "__main__":
940
+ config = TuneConfig.from_env()
941
+ success = main(config)
942
+ sys.exit(0 if success else 1)
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: tune-dms
3
+ Version: 0.1.0
4
+ Summary: TUNE DMS desktop GUI automation: login and report generation
5
+ License-Expression: MIT
6
+ Keywords: tune,dms,gui,automation,reports
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: pyautogui
16
+
17
+ # tune-dms
18
+
19
+ TUNE DMS desktop GUI automation: log in and run reports (e.g. Parts Price List, Parts by Bin Location) via keyboard/screen automation.
20
+
21
+ Install: `pip install tune-dms`
22
+ Or from repo root: `pip install -e ./packages/tune_dms`
23
+
24
+ See the [main repo README](../README.md) for configuration and usage.
@@ -0,0 +1,43 @@
1
+ tune_dms/__init__.py,sha256=AqosmWxyT-43xMgfA-JECy3THgoasmH2WNU5lIkhIUo,617
2
+ tune_dms/config.py,sha256=2E6V_T031ZpjsaO-mt1mF2KccBSBhPv9oL-9DJ8W4WU,1935
3
+ tune_dms/launcher.py,sha256=MJaZYnnZ0egdXxa0SegoA81nM37rYLgz91wvogm3I_s,36693
4
+ tune_dms/images/confirm_save_as_dialog.png,sha256=zNTFAaV56FxEXlO8dtgrMmEzGqybIczkRVg3Fa5IFC0,1100
5
+ tune_dms/images/excel_application_screen.png,sha256=vkmYg466hyyRHfvE2mHlaMxZ-Ob7Q-I_kj0_RRc9P0g,527
6
+ tune_dms/images/save_as_window.png,sha256=IJRh7yL6wUSVEaWljJwHEvmFjBwpYNyAqUv9h_hPG0I,1733
7
+ tune_dms/images/tune_admin_purchase_orders_icon_selected.png,sha256=9rzlT5hsQF6OczfxlSAKQ5URGOhV35nf9nZZYN3wK9c,2002
8
+ tune_dms/images/tune_application_screen.png,sha256=1GnO27UzU3jRU9FOGmXCewC2fm8hUqbK2DBYVz39wq4,975
9
+ tune_dms/images/tune_cancel_button.png,sha256=sY2I1s7X94xK6BKDgrdv2STIqELnZyYMiCLKGd8h8oI,508
10
+ tune_dms/images/tune_cancel_button_2.png,sha256=JipzsYqLtrIwyP83DJ1u4EkhjOknyFK11VzFSZp6Qso,503
11
+ tune_dms/images/tune_cancel_button_c.png,sha256=VLMECAoretn-STQQ43msppUTP7r5psc2_XVjafPRcZI,522
12
+ tune_dms/images/tune_exit_button.png,sha256=AlEZamFjISaW7cGy_qPW6RBYGsfER0HJ4Z9iv1-ibAY,433
13
+ tune_dms/images/tune_login_error.png,sha256=kBM8RNt0pHKIHl8cd9R05Z3raC9omui50FgEwfZjSVQ,2256
14
+ tune_dms/images/tune_login_screen.png,sha256=SRxkt013051OOeU87VLQtxQ-CR0AJpfjPpWR7lQHwIo,65866
15
+ tune_dms/images/tune_login_screen_environment.png,sha256=jBJo5XD0klEK7k2vB9Kv33QBNzot0ph4i-ul2YyRdcY,620
16
+ tune_dms/images/tune_part_general_enquiry_cap_qty.png,sha256=nRErc88LyNkZ2IS0myGb2qbBht3IndhFJ3f75m13Z9M,285
17
+ tune_dms/images/tune_part_general_enquiry_dangerous_goods_class.png,sha256=PLlBhUbwbtI942DJhS3nyBRU1zzBeOZ3V0K9KuhPIL8,447
18
+ tune_dms/images/tune_part_general_enquiry_default_supplier.png,sha256=lwBbigDU7oNmDyKBAHPLdRgQX1UV2Hv1U5etmEG5fdw,363
19
+ tune_dms/images/tune_part_general_enquiry_other.png,sha256=7mFLZo8Xora_8CAHl69haRa9MLyQxoZZqiqqvlW56vA,345
20
+ tune_dms/images/tune_part_general_enquiry_pack_size.png,sha256=q5R8JKHK02aYNbeJDxGxUCeMFl_d9lF4SFszATIQglU,302
21
+ tune_dms/images/tune_part_general_enquiry_part.png,sha256=LerhCawK1hG9ZjvNR6lvX1BD9Ejbsmzp8TjYy-qsnhg,239
22
+ tune_dms/images/tune_part_general_enquiry_part_height.png,sha256=ZMbCXiKYcQvkPxE3eezMGTKkFAGIIxNvLlHnMS7pg8c,321
23
+ tune_dms/images/tune_part_general_enquiry_part_length.png,sha256=vRQ5o_Kvwo6XDMYr2c_hl1lnHMXeA7mG8j5A8esCL44,305
24
+ tune_dms/images/tune_part_general_enquiry_part_volume.png,sha256=lTlOzIu34I32WKfvfYanoErViFYcdlfZJo7QJ2PEOUc,294
25
+ tune_dms/images/tune_part_general_enquiry_part_weight.png,sha256=oE2k5m842rlw9sNR8Yxt57oCNJtJeWGJWoLGC7MGvNk,312
26
+ tune_dms/images/tune_part_general_enquiry_part_width.png,sha256=-fr5JvKU4RdzcO9QtxIkzOywVcWLz_16hIK5pDMHKPM,294
27
+ tune_dms/images/tune_part_general_enquiry_unit_of_measure.png,sha256=C7t5dj4y3mm8HJr_6e4mFMKEx0XdFBYJbI4jTmCD4fU,353
28
+ tune_dms/images/tune_previous_button.png,sha256=j55BAvWhnxaPw9IZalAXhgHc6-G0Zd01fqvobEkU1Ok,526
29
+ tune_dms/images/tune_report_parts_by_bin_location.png,sha256=fmVt236qzjUk2mYf0oLMg-VmXmPlqaK1kDJQ94uZrZ8,1684
30
+ tune_dms/images/tune_report_parts_price_list.png,sha256=COjUuiFn4-CEDKeOlOwHoR4Jr0SP1SvQOf093NenZ1o,1136
31
+ tune_dms/images/tune_search_field.png,sha256=uCzFpgrv_71mvqN-3uxkykw_-TKdwYnAXsPvxXZq0ew,1854
32
+ tune_dms/images/tune_select_franchise.png,sha256=hkwCMPZV6F6Yft_x_2Ud1TCnXwwOHRiI9uBV8Kv2pAw,936
33
+ tune_dms/images/tune_select_franchise_grey.png,sha256=0Dp5e0FsmXl3zdLS3oJA8BKmmuutezoC9d3-V2yV4x8,855
34
+ tune_dms/images/tune_select_franchise_toy_grey.png,sha256=lQhOP7lXWhZwLV9l-iOQ3NsW_BaRyxEnhk7fho_fCGE,219
35
+ tune_dms/images/tune_select_franchise_toy_grey_box.png,sha256=rrid7bKxZguvz_lxGkXwiGehHfDPL28rFSaqRefi2iI,344
36
+ tune_dms/images/tune_select_franchise_toy_yellow.png,sha256=QgQom3rtEoJUAcRXYOt4wfaZXKZtGc19BZZDjtPDBBQ,216
37
+ tune_dms/images/tune_select_franchise_toy_yellow_box.png,sha256=hCy0pfVLychIUBqwEz0pg0NDiJWeaIUsppzqXMYoj-U,311
38
+ tune_dms/images/tune_supplier_part_selection.png,sha256=diXleZHo4emsAhblyO8Z_v81uvdACVqUWLbi5hUdxHE,1213
39
+ tune_dms/images/tune_x_button.png,sha256=LncxIEGnSdZpSdsWxQIfQ1oG04AseUgl41H30o6xaAs,313
40
+ tune_dms-0.1.0.dist-info/METADATA,sha256=D3BnGt6-zSgENLcfRHZQ-W6ssoS3shJLZA7GBO42HNs,900
41
+ tune_dms-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
42
+ tune_dms-0.1.0.dist-info/top_level.txt,sha256=foSEFiroat3zXAH5sxCZMw7NVXVnFlLH9idIe_QrOOs,9
43
+ tune_dms-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tune_dms