wizata-dsapi 2.2.0.dev21__tar.gz → 2.2.0.dev23__tar.gz

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 (75) hide show
  1. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/PKG-INFO +1 -1
  2. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/__init__.py +2 -0
  3. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/connection_types.py +11 -0
  4. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/inventory.py +248 -0
  5. wizata_dsapi-2.2.0.dev23/wizata_dsapi/version.py +1 -0
  6. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/wizata_dsapi_client.py +68 -1
  7. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi.egg-info/PKG-INFO +1 -1
  8. wizata_dsapi-2.2.0.dev21/wizata_dsapi/version.py +0 -1
  9. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/LICENSE.txt +0 -0
  10. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/README.rst +0 -0
  11. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/setup.cfg +0 -0
  12. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/setup.py +0 -0
  13. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/api_config.py +0 -0
  14. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/api_dto.py +0 -0
  15. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/api_interface.py +0 -0
  16. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/bucket.py +0 -0
  17. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/business_label.py +0 -0
  18. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/connection.py +0 -0
  19. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/connection_discovery.py +0 -0
  20. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/connection_policy.py +0 -0
  21. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/context.py +0 -0
  22. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/dashboard.py +0 -0
  23. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/dataframe_toolkit.py +0 -0
  24. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/datapoint.py +0 -0
  25. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/datastore.py +0 -0
  26. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/deployment.py +0 -0
  27. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/ds_dataframe.py +0 -0
  28. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/dsapi_json_encoder.py +0 -0
  29. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/edge_config.py +0 -0
  30. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/edge_device.py +0 -0
  31. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/edge_module.py +0 -0
  32. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/edge_package.py +0 -0
  33. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/evaluation.py +0 -0
  34. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/execution.py +0 -0
  35. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/execution_log.py +0 -0
  36. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/experiment.py +0 -0
  37. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/formula.py +0 -0
  38. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/graylog_log.py +0 -0
  39. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/group_system.py +0 -0
  40. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/ilogger.py +0 -0
  41. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/insight.py +0 -0
  42. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/mlmodel.py +0 -0
  43. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/mobile_asset.py +0 -0
  44. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/model_toolkit.py +0 -0
  45. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/models/__init__.py +0 -0
  46. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/models/common.py +0 -0
  47. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/notification.py +0 -0
  48. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/paged_query_result.py +0 -0
  49. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/pipeline.py +0 -0
  50. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/pipeline_image.py +0 -0
  51. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/plot.py +0 -0
  52. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/plots/__init__.py +0 -0
  53. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/plots/common.py +0 -0
  54. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/plots/theme.py +0 -0
  55. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/product.py +0 -0
  56. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/request.py +0 -0
  57. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/script.py +0 -0
  58. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/scripts/__init__.py +0 -0
  59. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/scripts/common.py +0 -0
  60. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/search.py +0 -0
  61. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/solution_component.py +0 -0
  62. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/streamlit_utils.py +0 -0
  63. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/template.py +0 -0
  64. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/template_config.py +0 -0
  65. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/trigger.py +0 -0
  66. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/twin.py +0 -0
  67. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/twinregistration.py +0 -0
  68. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/user.py +0 -0
  69. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/wizard_function.py +0 -0
  70. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/wizard_request.py +0 -0
  71. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi/words.py +0 -0
  72. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi.egg-info/SOURCES.txt +0 -0
  73. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi.egg-info/dependency_links.txt +0 -0
  74. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi.egg-info/requires.txt +0 -0
  75. {wizata_dsapi-2.2.0.dev21 → wizata_dsapi-2.2.0.dev23}/wizata_dsapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wizata_dsapi
3
- Version: 2.2.0.dev21
3
+ Version: 2.2.0.dev23
4
4
  Summary: Wizata Data Science Toolkit
5
5
  Author: Wizata S.A.
6
6
  Author-email: info@wizata.com
@@ -75,6 +75,8 @@ from .connection_discovery import ConnectionSeenName, ConnectionSeenReport, Conn
75
75
  from .product import ProductKind, Product, ProductComponent, BUILTIN_PRODUCT_KINDS, is_valid_product_key
76
76
  from .inventory import (InventoryBalance, InventoryStorage, InventoryStorageDatapoint, inventory_mode,
77
77
  InventoryStock, InventoryStockProduct, InventoryStockLine, InventoryStockCount, INVENTORY_OPTION, INVENTORY_MODES,
78
+ InventoryCount, InventoryCountLine, InventoryCountControl, InventoryHistory,
79
+ InventoryHistoryEvent, HISTORY_MOVEMENT, HISTORY_COUNT, round_quantity,
78
80
  INVENTORY_MOVEMENTS, INVENTORY_BALANCES, INVENTORY_STOCK,
79
81
  PRODUCT_TAG, PRODUCT_MESSAGE_KEY)
80
82
  from .dashboard import Dashboard, DashboardType
@@ -225,6 +225,17 @@ OPC_UA = ConnectionType(
225
225
  "enum": ["nodeid", "name", "identifier"], "default": "nodeid"},
226
226
  "reconnect_interval_ms": {"type": "integer", "title": "Reconnect interval (ms)",
227
227
  "minimum": 1000, "default": 5000},
228
+ # DEV-6118: how the reader's session rides out a server that answers late
229
+ "reconnect_max_ms": {"type": "integer", "title": "Max reconnect interval (ms)",
230
+ "minimum": 1000, "default": 60000},
231
+ "request_timeout_ms": {"type": "integer", "title": "Request timeout (ms)",
232
+ "description": "How long a read or the session keepalive waits "
233
+ "for the server before the session is given up",
234
+ "minimum": 500, "default": 10000},
235
+ "watchdog_interval_ms": {"type": "integer", "title": "Keepalive interval (ms)",
236
+ "minimum": 100, "default": 1000},
237
+ "dead_connection_polls": {"type": "integer", "title": "Failed polls before reconnecting",
238
+ "minimum": 1, "default": 2},
228
239
  "poll_interval_ms": _POLL,
229
240
  "nodes_from_policy": {"type": "boolean", "title": "Read the nodes listed in the policy",
230
241
  "default": False},
@@ -311,3 +311,251 @@ class InventoryStock:
311
311
  @classmethod
312
312
  def from_dict(cls, data: dict) -> "InventoryStock":
313
313
  return cls(as_of=data.get("asOf"), products=[InventoryStockProduct.from_dict(p) for p in data.get("products") or []])
314
+
315
+
316
+ def round_quantity(value):
317
+ """
318
+ A quantity kept to 12 significant digits: every real digit, none of the binary noise
319
+ that sums and unit conversions leave (0.1 + 0.2, 540000 mL in L). Expected and counted
320
+ levels are compared after this, so a count that matches is never off by 1e-13.
321
+ """
322
+ if value is None:
323
+ return None
324
+ return float(f"{float(value):.12g}")
325
+
326
+
327
+ def _twin_dict(twin_id, hardware_id, name) -> Optional[dict]:
328
+ if twin_id is None and hardware_id is None:
329
+ return None
330
+ return {"id": twin_id, "hardwareId": hardware_id, "name": name}
331
+
332
+
333
+ class InventoryCountLine:
334
+ """
335
+ One line of a count (DEV-6128): the balance, its storage, and what the movements
336
+ predicted at that moment. ``expected`` is the stock just before this count, without
337
+ it: the count before it plus the movements since, in the datapoint's unit.
338
+ """
339
+
340
+ def __init__(self, balance: InventoryBalance = None, twin_id=None, twin_hardware_id: str = None,
341
+ twin_name: str = None, unit=None, unit_symbol: str = None, expected: float = None):
342
+ self.balance: InventoryBalance = balance if balance is not None else InventoryBalance()
343
+ self.twin_id: Optional[str] = _uuid_str(twin_id)
344
+ self.twin_hardware_id: Optional[str] = twin_hardware_id
345
+ self.twin_name: Optional[str] = twin_name
346
+ self.unit: Optional[str] = _uuid_str(unit)
347
+ self.unit_symbol: Optional[str] = unit_symbol
348
+ self.expected: Optional[float] = expected
349
+
350
+ @property
351
+ def difference(self) -> Optional[float]:
352
+ """Counted minus expected: negative when stock went missing."""
353
+ if self.expected is None or self.balance.quantity is None:
354
+ return None
355
+ return round_quantity(round_quantity(self.balance.quantity) - round_quantity(self.expected))
356
+
357
+ @property
358
+ def verified(self) -> Optional[bool]:
359
+ """The count matches the movements exactly (after unit rounding)."""
360
+ if self.expected is None or self.balance.quantity is None:
361
+ return None
362
+ return round_quantity(self.balance.quantity) == round_quantity(self.expected)
363
+
364
+ def to_dict(self) -> dict:
365
+ obj = self.balance.to_dict()
366
+ obj.update({"twin": _twin_dict(self.twin_id, self.twin_hardware_id, self.twin_name),
367
+ "unit": self.unit, "unitSymbol": self.unit_symbol, "expected": self.expected,
368
+ "difference": self.difference, "verified": self.verified})
369
+ return obj
370
+
371
+ @classmethod
372
+ def from_dict(cls, data: dict) -> "InventoryCountLine":
373
+ twin = data.get("twin") or {}
374
+ balance = InventoryBalance.from_dict({k: v for k, v in data.items() if k != "unit"})
375
+ return cls(balance=balance, twin_id=twin.get("id"), twin_hardware_id=twin.get("hardwareId"),
376
+ twin_name=twin.get("name"), unit=data.get("unit"), unit_symbol=data.get("unitSymbol"),
377
+ expected=data.get("expected"))
378
+
379
+
380
+ class InventoryCount:
381
+ """
382
+ A count as one object (DEV-6128): every balance written by one call shares its
383
+ ``count`` key. ``timestamp`` is the moment they were all counted, or None when an
384
+ API sync wrote several moments in one call (each line then carries its own).
385
+
386
+ Read with ``GET /dsapi/planner/inventory/counts/<count>/``, replaced with ``PUT``
387
+ (the count becomes exactly the lines sent, all or none), deleted with ``DELETE``.
388
+ """
389
+
390
+ def __init__(self, count=None, lines: list = None):
391
+ self.count: Optional[str] = _uuid_str(count)
392
+ self.lines: list = list(lines or [])
393
+
394
+ @property
395
+ def timestamp(self) -> Optional[datetime]:
396
+ moments = {line.balance.timestamp for line in self.lines}
397
+ return moments.pop() if len(moments) == 1 else None
398
+
399
+ def _first(self, attribute: str, latest: bool = False):
400
+ stamped = [(getattr(l.balance, attribute + "_at"), getattr(l.balance, attribute + "_by"))
401
+ for l in self.lines if getattr(l.balance, attribute + "_at") is not None]
402
+ if not stamped:
403
+ return None, None
404
+ return (max if latest else min)(stamped, key=lambda s: s[0])
405
+
406
+ def to_dict(self) -> dict:
407
+ created_at, created_by = self._first("created")
408
+ updated_at, updated_by = self._first("updated", latest=True)
409
+ timestamp = self.timestamp
410
+ return {"count": self.count,
411
+ "timestamp": _ts_out(timestamp) if isinstance(timestamp, datetime) else timestamp,
412
+ "createdAt": _ts_out(created_at) if isinstance(created_at, datetime) else created_at,
413
+ "createdBy": created_by,
414
+ "updatedAt": _ts_out(updated_at) if isinstance(updated_at, datetime) else updated_at,
415
+ "updatedBy": updated_by,
416
+ "lines": [line.to_dict() for line in self.lines]}
417
+
418
+ @classmethod
419
+ def from_dict(cls, data: dict) -> "InventoryCount":
420
+ return cls(count=data.get("count"), lines=[InventoryCountLine.from_dict(l) for l in data.get("lines") or []])
421
+
422
+ def __repr__(self) -> str:
423
+ return f"InventoryCount({self.count!r}, {len(self.lines)} lines)"
424
+
425
+
426
+ class InventoryCountControl:
427
+ """
428
+ How a count compares with the movements (DEV-6129): ``previous`` count (None when
429
+ never counted before), the net quantity ``moved`` since it, so ``expected`` =
430
+ previous + moved, then ``counted``, their ``difference`` and whether it is ``verified``
431
+ (difference 0 after unit rounding).
432
+ """
433
+
434
+ def __init__(self, previous: InventoryStockCount = None, moved: float = None, expected: float = None,
435
+ counted: float = None):
436
+ self.previous: Optional[InventoryStockCount] = previous
437
+ self.moved: Optional[float] = moved
438
+ self.expected: Optional[float] = expected
439
+ self.counted: Optional[float] = counted
440
+
441
+ @property
442
+ def difference(self) -> Optional[float]:
443
+ if self.expected is None or self.counted is None:
444
+ return None
445
+ return round_quantity(round_quantity(self.counted) - round_quantity(self.expected))
446
+
447
+ @property
448
+ def verified(self) -> Optional[bool]:
449
+ if self.expected is None or self.counted is None:
450
+ return None
451
+ return round_quantity(self.counted) == round_quantity(self.expected)
452
+
453
+ def to_dict(self) -> dict:
454
+ return {"previous": self.previous.to_dict() if self.previous is not None else None,
455
+ "moved": self.moved, "expected": self.expected, "counted": self.counted,
456
+ "difference": self.difference, "verified": self.verified}
457
+
458
+ @classmethod
459
+ def from_dict(cls, data: dict) -> "InventoryCountControl":
460
+ previous = data.get("previous")
461
+ return cls(previous=InventoryStockCount.from_dict(previous) if previous else None,
462
+ moved=data.get("moved"), expected=data.get("expected"), counted=data.get("counted"))
463
+
464
+
465
+ #: The two kinds of event a stock history holds.
466
+ HISTORY_MOVEMENT = "movement"
467
+ HISTORY_COUNT = "count"
468
+
469
+
470
+ class InventoryHistoryEvent:
471
+ """
472
+ One event of a stock history, in time order (DEV-6129): a ``movement`` (signed
473
+ ``quantity``) or a ``count`` (counted ``quantity``, its ``count`` key, balance ``id``,
474
+ who recorded it and its ``control``). ``stock`` is the level right after it.
475
+ """
476
+
477
+ def __init__(self, timestamp=None, type: str = None, quantity: float = None, stock: float = None,
478
+ count=None, balance_id=None, by: str = None, control: InventoryCountControl = None):
479
+ self.timestamp: Optional[datetime] = _ts_in(timestamp)
480
+ self.type: Optional[str] = type
481
+ self.quantity: Optional[float] = quantity
482
+ self.stock: Optional[float] = stock
483
+ self.count: Optional[str] = _uuid_str(count)
484
+ self.id: Optional[str] = _uuid_str(balance_id)
485
+ self.by: Optional[str] = by
486
+ self.control: Optional[InventoryCountControl] = control
487
+
488
+ def to_dict(self) -> dict:
489
+ obj = {"timestamp": _ts_out(self.timestamp) if isinstance(self.timestamp, datetime) else self.timestamp,
490
+ "type": self.type, "quantity": self.quantity, "stock": self.stock}
491
+ if self.type == HISTORY_COUNT:
492
+ obj.update({"count": self.count, "id": self.id, "by": self.by,
493
+ "control": self.control.to_dict() if self.control is not None else None})
494
+ return obj
495
+
496
+ @classmethod
497
+ def from_dict(cls, data: dict) -> "InventoryHistoryEvent":
498
+ control = data.get("control")
499
+ return cls(timestamp=data.get("timestamp"), type=data.get("type"), quantity=data.get("quantity"),
500
+ stock=data.get("stock"), count=data.get("count"), balance_id=data.get("id"), by=data.get("by"),
501
+ control=InventoryCountControl.from_dict(control) if control else None)
502
+
503
+
504
+ class InventoryHistory:
505
+ """
506
+ How one product's stock moved on one storage datapoint over a period, and whether
507
+ each count matched (DEV-6129, ``POST /dsapi/planner/inventory/history``).
508
+
509
+ ``opening`` is the stock at ``start``, ``closing`` at ``end``; ``events`` are every
510
+ movement and count in between, oldest first, each with the stock after it. A count
511
+ carries its control against the count before it, even when that one is before
512
+ ``start``. ``summary`` counts the counts, the verified ones and the ``unexplained``
513
+ quantity (the sum of the differences). Past the event limit, ``truncated`` is true
514
+ and only the latest events are kept; the summary still covers the whole period.
515
+ """
516
+
517
+ def __init__(self, datapoint: str = None, product: str = None, start=None, end=None, unit=None,
518
+ unit_symbol: str = None, opening: float = None, closing: float = None, events: list = None,
519
+ truncated: bool = False):
520
+ self.datapoint: Optional[str] = datapoint
521
+ self.product: Optional[str] = product
522
+ self.start: Optional[datetime] = _ts_in(start)
523
+ self.end: Optional[datetime] = _ts_in(end)
524
+ self.unit: Optional[str] = _uuid_str(unit)
525
+ self.unit_symbol: Optional[str] = unit_symbol
526
+ self.opening: Optional[float] = opening
527
+ self.closing: Optional[float] = closing
528
+ self.events: list = list(events or [])
529
+ self.truncated: bool = truncated
530
+ self.counts: int = 0
531
+ self.verified: int = 0
532
+ self.unexplained: float = 0.0
533
+
534
+ def to_dict(self) -> dict:
535
+ def ts(value):
536
+ return _ts_out(value) if isinstance(value, datetime) else value
537
+ return {"datapoint": self.datapoint, "product": self.product, "unit": self.unit,
538
+ "unitSymbol": self.unit_symbol,
539
+ "opening": {"timestamp": ts(self.start), "quantity": self.opening},
540
+ "closing": {"timestamp": ts(self.end), "quantity": self.closing},
541
+ "events": [e.to_dict() for e in self.events],
542
+ "summary": {"counts": self.counts, "verified": self.verified, "unexplained": self.unexplained},
543
+ "truncated": self.truncated}
544
+
545
+ @classmethod
546
+ def from_dict(cls, data: dict) -> "InventoryHistory":
547
+ opening, closing = data.get("opening") or {}, data.get("closing") or {}
548
+ summary = data.get("summary") or {}
549
+ obj = cls(datapoint=data.get("datapoint"), product=data.get("product"), start=opening.get("timestamp"),
550
+ end=closing.get("timestamp"), unit=data.get("unit"), unit_symbol=data.get("unitSymbol"),
551
+ opening=opening.get("quantity"), closing=closing.get("quantity"),
552
+ events=[InventoryHistoryEvent.from_dict(e) for e in data.get("events") or []],
553
+ truncated=bool(data.get("truncated")))
554
+ obj.counts = summary.get("counts", 0)
555
+ obj.verified = summary.get("verified", 0)
556
+ obj.unexplained = summary.get("unexplained", 0.0)
557
+ return obj
558
+
559
+ def __repr__(self) -> str:
560
+ return (f"InventoryHistory({self.datapoint!r}, {self.product!r}, {len(self.events)} events, "
561
+ f"{self.verified}/{self.counts} counts verified)")
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0.dev23"
@@ -56,7 +56,7 @@ from .version import __version__
56
56
  import ast
57
57
  from .deployment import Deployment, DeploymentStatus
58
58
  from .product import ProductKind, Product, ProductComponent
59
- from .inventory import InventoryBalance, InventoryStorage, InventoryStock
59
+ from .inventory import InventoryBalance, InventoryStorage, InventoryStock, InventoryCount, InventoryHistory
60
60
  from .connection import EdgeDeviceKey
61
61
  from .edge_config import EdgeConfig
62
62
  from .api_config import _registry
@@ -3001,6 +3001,73 @@ class WizataDSAPIClient(ApiInterface, ApiDtoInterface):
3001
3001
  self._planner_request("DELETE", f"planner/inventory/balances/{self._balance_id(balance)}/",
3002
3002
  expected=(204,))
3003
3003
 
3004
+ @staticmethod
3005
+ def _count_key(value) -> str:
3006
+ key = value.count if isinstance(value, (InventoryCount, InventoryBalance)) else value
3007
+ if not key:
3008
+ raise ValueError("please provide a count key")
3009
+ return urllib.parse.quote(str(key), safe="")
3010
+
3011
+ def get_inventory_count(self, count):
3012
+ """
3013
+ A count as one object (DEV-6128): its lines (balances) with their storage, unit, and
3014
+ the level the movements predicted for each (``expected``, ``difference``,
3015
+ ``verified``).
3016
+
3017
+ :param count: the count key, or an InventoryBalance / InventoryCount carrying it.
3018
+ :return: InventoryCount, or None when no balance carries that key.
3019
+ """
3020
+ response = self._planner_request("GET", f"planner/inventory/counts/{self._count_key(count)}/",
3021
+ expected=(200, 404))
3022
+ return InventoryCount.from_dict(response.json()) if response.status_code == 200 else None
3023
+
3024
+ def set_inventory_count(self, count, balances: list, timestamp=None) -> InventoryCount:
3025
+ """
3026
+ Replace a count, all or none: afterwards it holds exactly ``balances``.
3027
+
3028
+ A balance with an ``id`` changes that line (quantity, time; never its datapoint or
3029
+ product). One without takes the count's line of the same datapoint and product, or
3030
+ joins the count. A line left out is deleted. ``timestamp`` (datetime or ISO 8601) is
3031
+ the time of every balance that has none. 409 when another count holds one of those
3032
+ products at that time.
3033
+
3034
+ :param count: the count key, or an InventoryCount.
3035
+ :param balances: InventoryBalance list (or dicts).
3036
+ :return: the count afterwards, as :meth:`get_inventory_count` returns it.
3037
+ """
3038
+ body = {"balances": [b.to_dict() if isinstance(b, InventoryBalance) else b for b in balances]}
3039
+ if timestamp is not None:
3040
+ body["timestamp"] = timestamp.isoformat() if isinstance(timestamp, datetime.datetime) else timestamp
3041
+ response = self._planner_request("PUT", f"planner/inventory/counts/{self._count_key(count)}/", body=body)
3042
+ return InventoryCount.from_dict(response.json())
3043
+
3044
+ def delete_inventory_count(self, count):
3045
+ """
3046
+ Delete every balance of a count.
3047
+
3048
+ :param count: the count key, or an InventoryCount / InventoryBalance carrying it.
3049
+ """
3050
+ self._planner_request("DELETE", f"planner/inventory/counts/{self._count_key(count)}/", expected=(204,))
3051
+
3052
+ def get_inventory_history(self, datapoint: str, product: str, start=None, end=None) -> InventoryHistory:
3053
+ """
3054
+ How one product's stock moved on one storage datapoint (DEV-6129): every movement and
3055
+ count in time order with the stock after each, and each count compared with the count
3056
+ before it plus the movements since (``control``: expected, counted, difference,
3057
+ verified). Default period: the last 7 days.
3058
+
3059
+ :param datapoint: hardware id of the storage's inventoryMovement datapoint.
3060
+ :param product: product key.
3061
+ :param start: datetime, ISO 8601, epoch ms or a relative ``now-7d``.
3062
+ :param end: same; default now.
3063
+ """
3064
+ body = {"datapoint": datapoint, "product": product}
3065
+ for name, value in (("start", start), ("end", end)):
3066
+ if value is not None:
3067
+ body[name] = value.isoformat() if isinstance(value, datetime.datetime) else value
3068
+ response = self._planner_request("POST", "planner/inventory/history", body=body)
3069
+ return InventoryHistory.from_dict(response.json())
3070
+
3004
3071
  # ------------------------------------------------------------ edge (DEV-6013/6014)
3005
3072
 
3006
3073
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wizata_dsapi
3
- Version: 2.2.0.dev21
3
+ Version: 2.2.0.dev23
4
4
  Summary: Wizata Data Science Toolkit
5
5
  Author: Wizata S.A.
6
6
  Author-email: info@wizata.com
@@ -1 +0,0 @@
1
- __version__ = "2.2.0.dev21"