예제 #1
0
class WeatherStation(Model.Iot.State):
    """Weather state from APRS-IS packet"""

    STATE_TYPE = "WEATHER_STATION"

    uuid: uuid4 = UUID(
        primary_key=True,
        default=uuid4,
        binary=False,
        foreign_key=Model.Iot.State.use("uuid").options(ondelete="CASCADE"),
    )
    sensor_date: DateTime = DateTime(
        label="Sensor timestamp",
        index=True,
    )
    station_code: str = String(label="Station code",
                               unique=False,
                               index=True,
                               nullable=False)

    wind_direction: D = Decimal(label="Wind direction")
    wind_speed: D = Decimal(label="Wind Speed (km/h ?)")
    wind_gust: D = Decimal(label="Wind gust (km/h ?)")
    temperature: D = Decimal(label="Thermometer (°C)")
    rain_1h: D = Decimal(label="rain (mm/1h)")
    rain_24h: D = Decimal(label="rain (mm/24h)")
    rain_since_midnight: D = Decimal(label="rain (mm/since midnight)")
    humidity: D = Decimal(label="Humidity (%)")
    pressure: D = Decimal(label="Pressure (hPa)")
    luminosity: D = Decimal(label="Luminosity/irradiation (W/m2)")

    @classmethod
    def define_table_args(cls):
        res = super().define_table_args()
        return res + (UniqueConstraint(cls.sensor_date, cls.station_code), )

    @classmethod
    def get_device_state(
        cls,
        code: str,
    ) -> Union["registry.Model.Iot.State.Relay",
               "registry.Model.Iot.State.DesiredRelay",
               "registry.Model.Iot.State.Thermometer",
               "registry.Model.Iot.State.FuelGauge",
               "registry.Model.Iot.State.WeatherStation", ]:
        """Overwrite parent method to sort on sensor date and set fake date
        while creating empty state
        """
        Device = cls.registry.Iot.Device
        state = (cls.query().join(Device).filter(Device.code == code).order_by(
            cls.registry.Iot.State.WeatherStation.sensor_date.desc()).first())
        if not state:
            device = Device.query().filter_by(code=code).one()
            # We don't want to instert a new state here, just creating
            # a default instance
            state = cls(device=device, sensor_date=datetime.now())
            cls.registry.flush()
        return state
예제 #2
0
class DecrementCommitment(Model.REA.Entity):
    """
    Commitment is a promise or obligation of economic agents to perform
    an economic event in the future. For example, line items on a sales order
    represent commitments to sell goods.

    # Model-Driven Design Using Business Patterns
    # Authors: Hruby, Pavel
    # ISBN-10 3-540-30154-2 Springer Berlin Heidelberg New York
    # ISBN-13 978-3-540-30154-7 Springer Berlin Heidelberg New York
    """
    id = Integer(primary_key=True, foreign_key=Model.REA.Entity.use('id'))

    recipient = Many2One(label="Agent recipient",
                         model=Model.REA.Agent,
                         nullable=False)

    resource = Many2One(label="Reservation Resource",
                        model=Model.REA.Resource,
                        nullable=False)

    value = Decimal(label="Value decrement", default=decimalType(0))

    fulfilled = Boolean(label="Is Fulfilled", default=False)

    def fulfill(self):
        """
        :return: True if commitment is fulfilled
        """
        if not self.fulfilled:
            self.registry.REA.DecrementEvent.create_event_from_commitment(self)
            self.fulfilled = True
            return True
        return False
예제 #3
0
class IncrementEvent(Model.REA.Entity):
    """
    Economic Event represents either an increment or a decrement in the
    value of economic resources that are under the control of the enterprise.
    Some economic events occur instantaneously, such as sales of goods; some
    occur over time, such as rentals, labor acquisition, and provision and use
    of services.

    # Model-Driven Design Using Business Patterns
    # Authors: Hruby, Pavel
    # ISBN-10 3-540-30154-2 Springer Berlin Heidelberg New York
    # ISBN-13 978-3-540-30154-7 Springer Berlin Heidelberg New York
    """
    id = Integer(primary_key=True, foreign_key=Model.REA.Entity.use('id'))

    provider = Many2One(label="Agent provider",
                        model=Model.REA.Agent,
                        nullable=False)

    resource = Many2One(label='Resource Inflow',
                        model=Model.REA.Resource,
                        nullable=False)

    value = Decimal(label="increment value", default=decimalType(0))

    date = DateTime(label="Event Date",
                    default=lambda **kwargs: datetime.now())
예제 #4
0
class Item(Mixin.UuidColumn, Mixin.TrackModel):
    SCHEMA = PriceListItemSchema

    @classmethod
    def get_schema_definition(cls, **kwargs):
        return cls.SCHEMA(**kwargs)

    price_list = Many2One(label="Pricelist",
                          model=Declarations.Model.Sale.PriceList,
                          nullable=False,
                          one2many="price_list_items")
    item = Many2One(label="Product Item",
                    model=Declarations.Model.Product.Item,
                    nullable=False,
                    unique=True,
                    one2many="prices")
    unit_price_untaxed = Decimal(label="Price untaxed", default=D(0))
    unit_price = Decimal(label="Price", default=D(0))
    unit_tax = Decimal(label="Tax", default=D(0))

    def __str__(self):
        return "{self.item.code} {self.unit_price_untaxed}".format(self=self)

    def __repr__(self):
        return ("<PriceListItem(uuid={self.uuid}, item.code={self.item.code}, "
                "unit_price_untaxed={self.unit_price_untaxed}>").format(
                    self=self)

    @classmethod
    def create(cls, keep_gross=True, **kwargs):
        data = kwargs.copy()
        if cls.get_schema_definition:
            sch = cls.get_schema_definition(registry=cls.registry)
            data = sch.load(kwargs)
        net = data.get('unit_price_untaxed') or D(0)
        gross = data.get('unit_price') or D(0)
        tax = data.get('unit_tax') or D(0)
        price = compute_price(net=net,
                              gross=gross,
                              tax=tax,
                              keep_gross=keep_gross)
        data['unit_price_untaxed'] = price.net.amount
        data['unit_price'] = price.gross.amount
        data['unit_tax'] = compute_tax(tax)
        return cls.insert(**data)
예제 #5
0
class PhysObj:
    """Override to add the :attr:`quantity` field.
    """

    quantity = Decimal(label="Quantity", default=1)
    """Quantity

    Depending on the PhysObj Type, this represents in reality some physical
    measure (length of wire, weight of wheat) for PhysObj stored and handled
    in bulk, or a number of identical items, if goods are kept as individual
    pieces.

    There is no corresponding idea of a unit of measure for bulk PhysObj,
    as we believe it to be enough to represent it in the PhysObj Type already
    (which would be, e.g, respectively a meter of wire, a ton of wheat). Note
    that bulk PhysObj can be the result of some :ref:`op_unpack`, with the
    packaged version
    being itself handled as an individual piece (imagine spindles of 100m for
    the wire example) and further packable (pallets, containers…)

    This field has been defined as Decimal to cover a broad scope of
    applications. However, for performance reasons, applications are
    free to overload this column with other numeric types (i.e.,
    supporting transparently all the usual operations with the same
    syntax, both in Python and SQL).

    Examples :

    + applications not caring about fractional quantities
      might want to overload this column with an Integer column for
      extra performance (if that really makes a difference).

    + applications having to deal with fractional quantities not well
      behaved in decimal notation (e.g., thirds of cherry pies)
      may want to switch to a rational number type, such as ``mpq``
      type on the PostgreSQL side), although it's probably a better idea
      if there's an obvious common denominator to just use integers
      (following on the example, simply have PhysObj Types representing
      those thirds of pies alongside those representing the whole pies,
      and represent the first cutting of a slice as an
      Unpack)
    """
    @classmethod
    def define_table_args(cls):
        return super(PhysObj, cls).define_table_args() + (CheckConstraint(
            'quantity > 0', name='positive_qty'), )

    def __str__(self):
        return ("(id={self.id}, type={self.type}, "
                "quantity={self.quantity})").format(self=self)

    def __repr__(self):
        return ("Wms.PhysObj(id={self.id}, type={self.type!r}, "
                "quantity={self.quantity!r})".format(self=self))
예제 #6
0
class Range(Mixin.UuidColumn, Mixin.TrackModel):
    code: str = String(label="Code", index=True, nullable=False)
    start: time = Time(default=time(hour=0, minute=0))
    end: time = Time(default=time(hour=23, minute=59))
    celsius: D = Decimal(label="Thermometer (°C)")

    @classmethod
    def get_desired_living_room_temperature(cls,
                                            date: datetime) -> Optional[D]:
        if not date:
            date = datetime.now()
        subquery = (cls.query().distinct(
            cls.code).filter(cls.create_date < date).order_by(
                cls.code.desc()).order_by(cls.create_date.desc()).subquery())
        range_ = (cls.registry.query(
            subquery.c.celsius).select_from(subquery).filter(
                subquery.c.start <= date.time(),
                subquery.c.end > date.time()).first())
        if range_:
            return range_.celsius
        return None
예제 #7
0
class Arrival:
    """Override to add the :attr:`quantity` field.
    """

    quantity = Decimal(default=1)
    """Quantity of the PhysObj to be created.

    It defaults to 1 to help adding ``wms-quantity`` to a codebase.
    """
    @classmethod
    def define_table_args(cls):
        return super(Arrival, cls).define_table_args() + (CheckConstraint(
            'quantity > 0', name='positive_qty'), )

    def specific_repr(self):
        return ("physobj_type={self.physobj_type!r}, "
                "location={self.location!r}, "
                "quantity={self.quantity}").format(self=self)

    def after_insert(self):
        # TODO reduce duplication
        PhysObj = self.registry.Wms.PhysObj
        self_props = self.physobj_properties
        if self_props is None:
            props = None
        else:
            props = PhysObj.Properties.create(**self_props)

        goods = PhysObj.insert(type=self.physobj_type,
                               properties=props,
                               quantity=self.quantity,
                               code=self.physobj_code)
        PhysObj.Avatar.insert(
            obj=goods,
            location=self.location,
            outcome_of=self,
            state='present' if self.state == 'done' else 'future',
            dt_from=self.dt_execution,
        )
예제 #8
0
class Thermometer(Model.Iot.State):

    STATE_TYPE = "TEMPERATURE"

    uuid: uuid4 = UUID(
        primary_key=True,
        default=uuid4,
        binary=False,
        foreign_key=Model.Iot.State.use("uuid").options(ondelete="CASCADE"),
    )

    celsius: D = Decimal(label="Thermometer (°C)")

    @classmethod
    def get_last_value(cls, device_code: str):
        Device = cls.registry.Iot.Device
        last = (cls.query().join(Device).filter(
            Device.code == device_code).order_by(
                cls.registry.Iot.State.create_date.desc()).first())
        if last:
            return last.celsius
        return 0

    @classmethod
    def wait_min_return_desired_temperature(cls, device_sensor: DEVICE,
                                            device_min: DEVICE,
                                            device_max: DEVICE):
        # wait return temperature to get the DEVICE.MIN_RET_DESIRED.value
        # temperature before start burner again
        Device = cls.registry.Iot.Device
        State = cls.registry.Iot.State
        min_temp = cls.get_last_value(device_min.value)
        max_temp = cls.get_last_value(device_max.value)
        last_pick = (cls.query().join(Device).filter(
            Device.code == device_sensor.value).filter(
                cls.celsius >= max_temp).order_by(
                    State.create_date.desc()).first())
        if not last_pick:
            return False
        last_curve = (cls.query().join(Device).filter(
            Device.code == device_sensor.value).filter(
                cls.celsius <= min_temp).filter(
                    State.create_date > last_pick.create_date).order_by(
                        State.create_date.desc()).first())
        if not last_curve:
            return True
        return False

    @classmethod
    def get_living_room_avg(cls, date: datetime = None, minutes=5):
        avg = cls.get_sensor_avg(DEVICE.LIVING_ROOM_SENSOR.value,
                                 date=date,
                                 minutes=minutes)
        if not avg:
            return 0
        return avg

    @classmethod
    def get_sensor_avg(cls,
                       device_code: str,
                       date: datetime = None,
                       minutes: int = 15):
        if not date:
            date = datetime.now()
        Device = cls.registry.Iot.Device
        return (cls.query(func.avg(
            cls.celsius).label("average")).join(Device).filter(
                Device.code == device_code).filter(
                    cls.create_date > date - timedelta(minutes=minutes),
                    cls.create_date <= date,
                ).group_by(Device.code).scalar())
예제 #9
0
class Order(Mixin.UuidColumn, Mixin.TrackModel, Mixin.WorkFlow):
    """Sale.Order model
    """
    SCHEMA = OrderBaseSchema

    @classmethod
    def get_schema_definition(cls, **kwargs):
        return cls.SCHEMA(**kwargs)

    @classmethod
    def get_workflow_definition(cls):

        return {
            'draft': {
                'default': True,
                'allowed_to': ['quotation', 'cancelled']
            },
            'quotation': {
                'allowed_to': ['order', 'cancelled'],
                'validators':
                SchemaValidator(
                    cls.get_schema_definition(exclude=['price_list']))
            },
            'order': {
                'validators':
                SchemaValidator(
                    cls.get_schema_definition(exclude=['price_list']))
            },
            'cancelled': {},
        }

    code = String(label="Code", nullable=False)
    channel = String(label="Sale Channel", nullable=False)
    price_list = Many2One(label="Price list",
                          model=Declarations.Model.Sale.PriceList)
    delivery_method = String(label="Delivery Method")

    amount_untaxed = Decimal(label="Amount Untaxed", default=D(0))
    amount_tax = Decimal(label="Tax amount", default=D(0))
    amount_total = Decimal(label="Total", default=D(0))

    def __str__(self):
        return "{self.uuid} {self.channel} {self.code} {self.state}".format(
            self=self)

    def __repr__(self):
        return "<Sale(id={self.uuid}, code={self.code}," \
               " amount_untaxed={self.amount_untaxed},"\
               " amount_tax={self.amount_tax},"\
               " amount_total={self.amount_total},"\
               " channel={self.channel} state={self.state})>".format(
                    self=self)

    @classmethod
    def create(cls, price_list=None, **kwargs):
        data = kwargs.copy()
        if cls.get_schema_definition:
            sch = cls.get_schema_definition(registry=cls.registry,
                                            exclude=['lines'])
            if price_list:
                data["price_list"] = price_list.to_primary_keys()
            data = sch.load(data)
            data['price_list'] = price_list

        return cls.insert(**data)

    def compute(self):
        """Compute order total amount"""
        amount_untaxed = D(0)
        amount_tax = D(0)
        amount_total = D(0)

        for line in self.lines:
            amount_untaxed += line.amount_untaxed
            amount_tax += line.amount_tax
            amount_total += line.amount_total

        self.amount_untaxed = amount_untaxed
        self.amount_tax = amount_tax
        self.amount_total = amount_total
예제 #10
0
class Line(Mixin.UuidColumn, Mixin.TrackModel):
    """Sale.Order.Line Model
    """
    SCHEMA = OrderLineBaseSchema

    @classmethod
    def get_schema_definition(cls, **kwargs):
        return cls.SCHEMA(**kwargs)

    order = Many2One(label="Order",
                     model=Declarations.Model.Sale.Order,
                     nullable=False,
                     one2many="lines")

    item = Many2One(label="Product Item",
                    model=Declarations.Model.Product.Item,
                    nullable=False)

    properties = Jsonb(label="Item properties", default=dict())

    unit_price_untaxed = Decimal(label="Price untaxed", default=D(0))
    unit_price = Decimal(label="Price", default=D(0))
    unit_tax = Decimal(label="Tax", default=D(0))

    quantity = Integer(label="Quantity", default=1, nullable=False)

    amount_untaxed = Decimal(label="Amount untaxed", default=D(0))
    amount_tax = Decimal(label="Tax amount", default=D(0))
    amount_total = Decimal(label="Total", default=D(0))

    amount_discount_percentage_untaxed = Decimal(
        label="Amount discount percentage untaxed", default=D(0))
    amount_discount_percentage = Decimal(label="Amount discount percentage",
                                         default=D(0))
    amount_discount_untaxed = Decimal(label="Amount discount untaxed",
                                      default=D(0))
    amount_discount = Decimal(label="Amount discount", default=D(0))

    def __str__(self):
        return "{self.uuid} : {self.amount_total}".format(self=self)

    def __repr__(self):
        return "<Sale.Order.Line(uuid={self.uuid},"\
               " amount_untaxed={self.amount_untaxed},"\
               " amount_tax={self.amount_tax},"\
               " amount_total={self.amount_total})>".format(self=self)

    def check_unit_price(self):
        """Ensure consistency between unit_price_untaxed, unit_price and
        unit_tax
        TODO: Move this to a specialized marshmallow validation method
        """
        if (self.unit_price_untaxed < D(0) or self.unit_price < D(0)
                or self.unit_tax < D(0)):
            raise LineException(
                """Negative Value forbidden on unit_price_untaxed, unit_price
                or unit_tax""")

        if (self.unit_price_untaxed != self.unit_price
                and self.unit_tax == D(0)):
            raise LineException(
                """Inconsistency between unit_price_untaxed, unit_price
                and unit_tax""")

        if self.unit_tax != D(0):
            if (self.unit_price_untaxed >= self.unit_price
                    and self.unit_price != D(0)):
                raise LineException(
                    """unit_price_untaxed can not be greater than unit_price"""
                )

    def compute(self):
        """Compute order line total amount

        * check unit_price consistency
        * compute tax if any
        * compute line total amount

        TODO: maybe add configuration options for computation behaviours, for
        example computation based on unit_price or unit_price_untaxed
        """

        if not self.order.price_list:
            self.check_unit_price()
            if self.unit_price != D(0) and self.unit_price_untaxed == D(0):
                # compute unit_price_untaxed based on unit_price
                price = compute_price(net=self.unit_price,
                                      gross=self.unit_price,
                                      tax=compute_tax(self.unit_tax),
                                      keep_gross=True)
            elif self.unit_price_untaxed != D(0) and self.unit_price == D(0):
                # compute unit_price based on unit_price_untaxed
                price = compute_price(net=self.unit_price_untaxed,
                                      gross=self.unit_price_untaxed,
                                      tax=compute_tax(self.unit_tax),
                                      keep_gross=False)
            elif self.unit_price_untaxed != D(0) and self.unit_price != D(0):
                # compute unit_price_untaxed based on unit_price
                price = compute_price(net=self.unit_price,
                                      gross=self.unit_price,
                                      tax=compute_tax(self.unit_tax),
                                      keep_gross=True)
            else:
                raise LineException(
                    """Can not find a strategy to compute price""")

            self.unit_price_untaxed = price.net.amount
            self.unit_price = price.gross.amount
            self.unit_tax = compute_tax(self.unit_tax)
        else:
            # compute unit price based on price list
            price_list_item = self.registry.Sale.PriceList.Item.query(
            ).filter_by(price_list=self.order.price_list).filter_by(
                item=self.item).one_or_none()
            if price_list_item:
                self.unit_price = price_list_item.unit_price
                self.unit_price_untaxed = price_list_item.unit_price_untaxed
                self.unit_tax = price_list_item.unit_tax
            else:
                raise LineException("""Can not find a price for %r on %r""" %
                                    (self.item, self.order.price_list))

        # compute total amount
        self.amount_total = D(self.unit_price * self.quantity)
        self.amount_untaxed = D(self.unit_price_untaxed * self.quantity)
        self.amount_tax = self.amount_total - self.amount_untaxed

        # compute total amount after discount
        if self.amount_discount_untaxed != D('0'):
            price = compute_price(net=self.amount_untaxed,
                                  tax=self.unit_tax,
                                  keep_gross=False)
            discount = compute_discount(
                price=price,
                tax=self.unit_tax,
                discount_amount=self.amount_discount_untaxed,
                from_gross=False)

            self.amount_total = discount.gross.amount
            self.amount_untaxed = discount.net.amount
            self.amount_tax = discount.tax.amount
            return

        if self.amount_discount_percentage_untaxed != D('0'):
            price = compute_price(net=self.amount_untaxed,
                                  tax=self.unit_tax,
                                  keep_gross=False)
            discount = compute_discount(
                price=price,
                tax=self.unit_tax,
                discount_percent=self.amount_discount_percentage_untaxed,
                from_gross=False)

            self.amount_total = discount.gross.amount
            self.amount_untaxed = discount.net.amount
            self.amount_tax = discount.tax.amount
            return

        if self.amount_discount != D('0'):
            price = compute_price(gross=self.amount_total,
                                  tax=self.unit_tax,
                                  keep_gross=True)
            discount = compute_discount(price=price,
                                        tax=self.unit_tax,
                                        discount_amount=self.amount_discount,
                                        from_gross=True)
            self.amount_total = discount.gross.amount
            self.amount_untaxed = discount.net.amount
            self.amount_tax = discount.tax.amount
            return

        if self.amount_discount_percentage != D('0'):
            price = compute_price(gross=self.amount_total,
                                  tax=self.unit_tax,
                                  keep_gross=True)
            discount = compute_discount(
                price=price,
                tax=self.unit_tax,
                discount_percent=self.amount_discount_percentage,
                from_gross=True)

            self.amount_total = discount.gross.amount
            self.amount_untaxed = discount.net.amount
            self.amount_tax = discount.tax.amount
            return

    @classmethod
    def create(cls, order=None, item=None, **kwargs):
        data = kwargs.copy()

        if order is None:
            raise TypeError

        if item is None:
            raise TypeError

        if cls.get_schema_definition:
            sch = cls.get_schema_definition(
                registry=cls.registry,
                required_fields=["order", "item", "quantity"])
            data['item'] = item.to_primary_keys()
            data['order'] = order.to_primary_keys()

            data = sch.load(data)

            data['item'] = item
            data['order'] = order

        line = cls.insert(**data)
        line.compute()
        return line

    @classmethod
    def before_update_orm_event(cls, mapper, connection, target):

        if cls.get_schema_definition:
            sch = cls.get_schema_definition(
                registry=cls.registry,
                required_fields=["order", "item", "quantity"])

            sch.load(sch.dump(target))

            if (target.properties
                    and cls.registry.System.Blok.is_installed('product_family')
                    and target.item.template.family.custom_schemas):
                props = target.item.template.family.custom_schemas.get(
                    target.item.code.lower()).get('schema')
                props_sch = props(context={"registry": cls.registry})
                props_sch.load(target.properties)
        target.compute()
예제 #11
0
class WmsSplitterOperation:
    """Mixin for operations on a single input that can split.

    This is to be applied after :class:`Mixin.WmsSingleInputOperation
    <anyblok_wms_base.core.operation.single_input.WmsSingleInputOperation>`.
    Use :class:`WmsSplitterSingleInputOperation` to get both at once.

    It defines the :attr:`quantity` field to express that the Operation only
    works on some of the quantity held by the PhysObj of the single input.

    In case the Operation's :attr:`quantity` is less than in the PhysObj
    record, a :class:`Split <.split.Split>` will be inserted properly in
    history, and the Operation implementation can ignore quantities
    completely, as it will always, in truth, work on the whole of the input
    it will see.

    Subclasses can use the :attr:`partial` field if they need to know
    if that happened, but this should be useful only in special cases.
    """
    quantity = Decimal(default=1)
    """The quantity this Operation will work on.

    Can be less than the quantity of our single input.
    """

    partial = Boolean(label="Operation induced a split")
    """Record if a Split will be or has been inserted in the history.

    Such insertions should happen if the operation's original PhysObj
    have greater quantity than the operation needs.

    This is useful because once the Split is executed,
    this information can't be deduced from the quantities involved any more.
    """
    @classmethod
    def define_table_args(cls):
        return super(WmsSplitterOperation, cls).define_table_args() + (
            CheckConstraint('quantity > 0', name='positive_qty'), )

    def specific_repr(self):
        return ("input={self.input!r}, "
                "quantity={self.quantity}").format(self=self)

    @classmethod
    def check_create_conditions(cls,
                                state,
                                dt_execution,
                                inputs=None,
                                quantity=None,
                                **kwargs):

        super(WmsSplitterOperation,
              cls).check_create_conditions(state,
                                           dt_execution,
                                           inputs=inputs,
                                           quantity=quantity,
                                           **kwargs)

        phobj = inputs[0].obj
        if quantity is None:
            raise OperationMissingQuantityError(
                cls, "The 'quantity keyword argument must be passed to "
                "the create() method")
        if quantity > phobj.quantity:
            raise OperationQuantityError(
                cls,
                "Can't split a greater quantity ({op_quantity}) than held in "
                "{input} (which have quantity={input.obj.quantity})",
                op_quantity=quantity,
                input=inputs[0])

    def check_execute_conditions(self):
        """Check that the quantity (after possible Split) is as on the input.

        If a Split has been inserted, then this calls the base class
        for the input of the Split, instead of ``self``, because the input of
        ``self`` is in that case the outcome of the Split, and it's normal
        that it's in state ``future``: the Split will be executed during
        ``self.execute()``, which comes once the present method has agreed.
        """
        if self.quantity != self.input.obj.quantity:
            raise OperationQuantityError(
                self,
                "Can't execute planned for a different quantity {op_quantity} "
                "than held in its input {input} "
                "(which have quantity={input.obj.quantity}). "
                "If it's less, a Split should have occured first ",
                input=input)
        if self.partial:
            self.input.outcome_of.check_execute_conditions()
        else:
            super(WmsSplitterOperation, self).check_execute_conditions()

    @classmethod
    def before_insert(cls,
                      state='planned',
                      follows=None,
                      inputs=None,
                      quantity=None,
                      dt_execution=None,
                      dt_start=None,
                      **kwargs):
        """Override to introduce a Split if needed

        In case the value of :attr:`quantity` does not match the one from the
        ``goods`` field, a :class:`Split <.split.Split>` is inserted
        transparently in the history, as if it'd been there in the first place:
        subclasses can implement :meth:`after_insert` as if the quantities were
        matching from the beginning.
        """
        avatar = inputs[0]
        partial = quantity < avatar.obj.quantity
        if not partial:
            return inputs, None

        Split = cls.registry.Wms.Operation.Split
        split = Split.create(input=avatar,
                             quantity=quantity,
                             state=state,
                             dt_execution=dt_execution)
        return [split.wished_outcome], dict(partial=partial)

    def execute_planned(self):
        """Execute the :class:`Split <.split.Split>` if any, then self."""
        if self.partial:
            split_op = next(iter(self.follows))
            split_op.execute(dt_execution=self.dt_execution)
        super(WmsSplitterOperation, self).execute_planned()
        self.registry.flush()
예제 #12
0
class Split(SingleInput, InPlace, Operation):
    """A split of PhysObj record in two.

    Splits replace their input's :class:`PhysObj
    <anyblok_wms_base.quantity.physobj.PhysObj>` record with
    two of them, one having the wished :attr:`quantity`, along with
    Avatars at the same location, while
    keeping the same properties and the same total quantity.

    This is therefore destructive for the input's PhysObj, which is not
    conceptually satisfactory, but will be good enough at this stage of
    development.

    While non trivial in the database, they may have no physical counterpart in
    the real world. We call them *formal* in that case.

    Formal Splits are operations of a special kind, that have to be considered
    internal details of ``wms-core``, that are not guaranteed to exist in the
    future.

    Formal Splits can always be reverted with
    :class:`Aggregate <.aggregate.Aggregate>` Operations,
    but only some physical Splits can be reverted, depending on
    the PhysObj Type.

    .. seealso:: :class:`Model.Wms.PhysObj.Type
                 <anyblok_wms_base.quantity.physobj.Type>`
                 for a full discussion including use-cases of formal and
                 physical splits and reversal of the latter.

    In the formal case, we've decided to represent this as an Operation for
    the sake of consistency, and especially to avoid too much special cases
    in implementation of various concrete Operations.

    The benefit is that Splits appear explicitely in the history, and this
    helps implementing :ref:`history manipulating methods
    <op_cancel_revert_obliviate>` a lot.

    The drawback is that we get a proliferation of PhysObj records, some of
    them even with a zero second lifespan, but even those could be simplified
    only for executed Splits.

    Splits are typically created and executed from :class:`Splitter Operations
    <.splitter.WmsSplitterOperation>`, and that explains the
    above-mentioned zero lifespans.
    """
    TYPE = 'wms_split'
    """Polymorphic key"""

    id = Integer(label="Identifier",
                 primary_key=True,
                 autoincrement=False,
                 foreign_key=Operation.use('id').options(ondelete='cascade'))

    quantity = Decimal()
    """The quantity to split."""

    def specific_repr(self):
        return ("input={self.input!r}, "
                "quantity={self.quantity}").format(self=self)

    def after_insert(self):
        self.registry.flush()
        avatar = self.input
        phobj = avatar.obj
        qty = self.quantity
        new_phobj = dict(
            type=phobj.type,
            code=phobj.code,
            properties=phobj.properties,
        )
        new_avatars = dict(
            location=avatar.location,
            outcome_of=self,
            dt_from=self.dt_execution,
            dt_until=avatar.dt_until,
        )
        avatar.dt_until = self.dt_execution
        if self.state == 'done':
            avatar.update(state='past')
            new_avatars['state'] = 'present'
        else:
            new_avatars['state'] = 'future'

        return tuple(
            avatar.insert(
                obj=phobj.insert(quantity=new_qty, **new_phobj),
                **new_avatars)
            for new_qty in (qty, phobj.quantity - qty))

    @property
    def wished_outcome(self):
        """Return the PhysObj record with the wished quantity.

        This is only one of :attr:`outcomes
        <anyblok_wms_base.core.operation.base.Operation.outcomes>`

        :rtype: :class:`Wms.PhysObj
                <anyblok_wms_base.core.physobj.PhysObj>`
        """
        PhysObj = self.registry.Wms.PhysObj
        Avatar = PhysObj.Avatar
        # in case the split is exactly in half, there's no difference
        # between the two records we created, let's pick any.
        outcome = (Avatar.query().join(Avatar.obj)
                   .filter(Avatar.outcome_of == self,
                           PhysObj.quantity == self.quantity)
                   .first())
        if outcome is None:
            raise OperationError(self, "The split outcomes have disappeared")
        return outcome

    def check_execute_conditions(self):
        """Call the base class's version and check that quantity is suitable.
        """
        super(Split, self).check_execute_conditions()
        phobj = self.input.obj
        if self.quantity > phobj.quantity:
            raise OperationQuantityError(
                self,
                "Can't execute {op}, whose quantity {op.quantity} is greater "
                "than on its input {phobj}, "
                "although it's been successfully planned.",
                op=self, phobj=self.input)

    def execute_planned(self):
        for outcome in self.outcomes:
            outcome.update(state='present', dt_from=self.dt_execution)
        self.registry.flush()
        self.input.update(state='past', dt_until=self.dt_execution)
        self.registry.flush()

    def is_reversible(self):
        """Reversibility depends on the relevant PhysObj Type.

        See :meth:`on Model.PhysObj.Type
        <anyblok_wms_base.core.physobj.Type.is_split_reversible>`
        """
        return self.input.obj.type.is_split_reversible()

    def plan_revert_single(self, dt_execution, follows=()):
        Wms = self.registry.Wms
        Avatars = Wms.PhysObj.Avatar
        # here in that case, that's for multiple operations
        # in_ is not implemented for Many2Ones
        reason_ids = set(f.id for f in follows)
        to_aggregate = (Avatars.query()
                        .filter(Avatars.outcome_of_id.in_(reason_ids))
                        .all())
        to_aggregate.extend(self.leaf_outcomes())
        return Wms.Operation.Aggregate.create(inputs=to_aggregate,
                                              dt_execution=dt_execution,
                                              state='planned')

    def obliviate_single(self):
        """Remove the created PhysObj in addition to base class operation.

        The base class would only take care of the created Avatars
        """
        outcomes_objs = [o.obj for o in self.outcomes]
        super(Split, self).obliviate_single()
        for obj in outcomes_objs:
            obj.delete()