Beispiel #1
0
    def check_create_conditions(cls,
                                state,
                                dt_execution,
                                inputs=None,
                                outcome_type=None,
                                name=None,
                                **kwargs):
        super(Assembly, cls).check_create_conditions(state,
                                                     dt_execution,
                                                     inputs=inputs,
                                                     **kwargs)
        behaviour = outcome_type.behaviours.get('assembly')
        if behaviour is None:
            raise OperationError(
                cls,
                "No assembly specified for type {outcome_type!r}",
                outcome_type=outcome_type)
        spec = behaviour.get(name)
        if spec is None:
            raise OperationError(
                cls,
                "No such assembly: {name!r} for type {outcome_type!r}",
                name=name,
                outcome_type=outcome_type)

        loc = inputs[0].location
        if any(inp.location != loc for inp in inputs[1:]):
            raise OperationInputsError(
                cls,
                "Inputs {inputs} are in different Locations: {locations!r}",
                inputs=inputs,
                # in the passing case, building a set would have been
                # useless overhead
                locations=set(inp.location for inp in inputs))
Beispiel #2
0
 def check_create_conditions(cls,
                             state,
                             dt_execution,
                             inputs=None,
                             outcome_type=None,
                             name=None,
                             **kwargs):
     super(Assembly, cls).check_create_conditions(state,
                                                  dt_execution,
                                                  inputs=inputs,
                                                  **kwargs)
     behaviour = outcome_type.behaviours.get('assembly')
     if behaviour is None:
         raise OperationError(
             cls,
             "No assembly specified for type {outcome_type!r}",
             outcome_type=outcome_type)
     spec = behaviour.get(name)
     if spec is None:
         raise OperationError(
             cls,
             "No such assembly: {name!r} for type {outcome_type!r}",
             name=name,
             outcome_type=outcome_type)
     cls.check_inputs_locations(inputs,
                                outcome_type=outcome_type,
                                name=name)
Beispiel #3
0
    def cancel(self):
        """Cancel a planned operation and all its consequences.

        This method will recursively cancel all follow-ups of ``self``, before
        cancelling ``self`` itself.

        The implementation is for now a simple recursion, and hence can
        lead to :class:`RecursionError` on huge graphs.
        TODO rewrite using an accumulation logic rather than recursion.
        """
        if self.state != 'planned':
            raise OperationError(
                self,
                "Can't cancel {op} because its state {op.state!r} is not "
                "'planned'",
                op=self)
        logger.debug("Cancelling operation %r", self)

        # followers attribute value will mutate during the loop
        followers = tuple(self.followers)
        for follower in followers:
            follower.cancel()
        self.cancel_single()
        self.follows.clear()
        self.delete()
        logger.info("Cancelled operation %r", self)
 def test_op_err_cls(self):
     op_err = OperationError(self.Arrival, "quantity is {qty}", qty=7)
     self.assertEqual(str(op_err),
                      "Model.Wms.Operation.Arrival: quantity is 7")
     self.assertEqual(
         repr(op_err), "OperationError(Model.Wms.Operation.Arrival, "
         "'quantity is {qty}', qty=7)")
Beispiel #5
0
 def check_alterable(self):
     """Raise OperationError if the Operation can't be altered."""
     if self.state in ('started', 'done'):
         raise OperationError(
             self,
             "Can't alter {op} because its state is {state}",
             op=self,
             state=self.state)
 def test_op_err_instance(self):
     op_err = OperationError(self.arrival, "quantity is {qty}", qty=7)
     self.assertEqual(str(op_err),
                      "Model.Wms.Operation.Arrival: quantity is 7")
     repr_err = repr(op_err)
     self.assertTrue(
         repr_err.startswith(
             "OperationError(Model.Wms.Operation.Arrival, 'quantity is {qty}', "
         ))
     self.assertTrue('qty=7' in repr_err)
     self.assertTrue("operation={op!r}".format(op=self.arrival) in repr_err)
Beispiel #7
0
    def obliviate(self):
        """Totally forget about an executed Operation and all its consequences.

        This is intended for cases where an Operation has been recorded by
        mistake (bug or human error), but did not happen at all in reality.

        We chose the word "obliviate" because it has a stronger feeling that
        simply "forget" and also sounds more specific.

        This is not to be confused with reversals, which try and create a
        chain of Operations whose execution would revert the effect of some
        Operations.

        If one reverts a Move that has been done by mistake,
        that means one performs a Move back (takes some time, can go wrong).
        If one obliviates a Move, that means one
        acknowledges that the Move never happened: its mere existence in the
        database is itself the mistake.

        Also, some Operations cannot be reverted in reality, whereas oblivion
        in our sense have no effect on reality.

        This method will recursively obliviate all follow-ups of ``self``,
        before ``self`` itself.

        The implementation is for now a simple recursion, and hence can
        lead to :class:`RecursionError` on huge graphs.
        TODO rewrite using an accumulation logic rather than recursion.
        TODO it is also very much a duplication of :meth:`cancel`. The
        recursion logic itself should probably be factorized in a common
        method.

        TODO For the time being, the implementation insists on all Operations
        to be in the ``done`` state, but it should probably accept those
        that are in the ``planned`` state, and call :meth:`cancel` on them,
        maybe this could become an option if we can't decide.
        """
        if self.state != 'done':
            raise OperationError(
                self,
                "Can't obliviate {op} because its state {op.state!r} is not "
                "'done'",
                op=self)
        logger.debug("Obliviating operation %r", self)

        # followers attribute value will mutate during the loop
        followers = tuple(self.followers)
        for follower in followers:
            follower.obliviate()
        self.obliviate_single()

        self.delete()
        # TODO check that we have a test for cascading on HistoryInput
        logger.info("Obliviated operation %r", self)
Beispiel #8
0
    def plan_revert(self, dt_execution=None):
        """Plan operations to revert the present one and its consequences.

        Like :meth:`cancel`, this method is recursive, but it applies only
        to operations that are in the 'done' state.

        It is expected that some operations can't be reverted, because they
        are destructive, and in that case an exception will be raised.

        For now, time handling is rather dumb, as it will plan
        all the operations at the same date and time (this Blok has to idea
        of operations lead times), but that shouldn't be a problem.

        :param datetime dt_execution:
           the time at which to plan the reversal operations.
           If not supplied, the current date and time will be used.

        :rtype: (``Operation``, list(``Operation``))
        :return: the operation reverting the present one, and
                 the list of initial operations to be executed to actually
                 start reversing the whole.
        """
        if dt_execution is None:
            dt_execution = datetime.now(tz=UTC)
        if self.state != 'done':
            # TODO actually it'd be nice to cancel or update
            # planned operations (think of reverting a Move meant for
            # organisation, but keeping an Unpack that was scheduled
            # afterwards)
            raise OperationError(self, "Can't plan reversal of {op} because "
                                 "its state {op.state!r} is not 'done'",
                                 op=self)
        if not self.is_reversible():
            raise OperationIrreversibleError(self)

        logger.debug("Planning reversal of operation %r", self)

        exec_leafs = []
        followers_reverts = []
        for follower in self.followers:
            follower_revert, follower_exec_leafs = follower.plan_revert(
                dt_execution=dt_execution)
            self.registry.flush()
            followers_reverts.append(follower_revert)
            exec_leafs.extend(follower_exec_leafs)
        this_reversal = self.plan_revert_single(dt_execution,
                                                follows=followers_reverts)
        self.registry.flush()
        if not exec_leafs:
            exec_leafs.append(this_reversal)
        logger.info(
            "Planned reversal of operation %r. "
            "Execution starts with %r", self, exec_leafs)
        return this_reversal, exec_leafs
Beispiel #9
0
    def alter_dt_execution(self, new_dt):
        """Change the date/time execution of a planned Operation.

        :param datetime new_dt: new value for the :attr:`dt_execution` field.

        This method takes care to maintain consistency by changing the
        outcomes date/time fields and recurse to the followers if
        needed.

        This basic implementation is minimal in that all it cares about
        is the data consistency: Avatars can't overlap, and all followers of
        an Operation should happen after it. Therefore, instead of propagating
        the time deltas, it will stop once these conditions are restored, and
        won't hesitate to produce Avatars with a zero time span (which will be
        rewritten later anyway). This has the advantage of not being too slow.

        This is typically called by :meth:`execute` and :meth:`start`
        but applications really caring about planned execution times can also
        make use of this (they may also want a true propagation to occur,
        which is not supported yet, but that's a different story)
        """
        self.check_alterable()
        self.dt_execution = new_dt
        for av in self.inputs:
            if av.dt_from > new_dt:
                # TODO more precise exc
                raise OperationError(
                    self, "Can't alter dt_execution to "
                    "before input presence time")
            av.dt_until = new_dt
        Wms = self.registry.Wms
        Operation = Wms.Operation
        HI = Operation.HistoryInput
        Avatar = Wms.PhysObj.Avatar
        res = (self.registry.query(Avatar).filter_by(
            outcome_of=self).outerjoin(
                HI, HI.avatar_id == Avatar.id).outerjoin(
                    Operation, HI.operation_id == Operation.id).add_entity(
                        Operation).all())
        followers = {}  # op -> input avatars
        for av, op in res:  # TODO better use an array_agg or something
            followers.setdefault(op, []).append(av)

        for follower, avs in followers.items():
            for av in avs:
                av.dt_from = new_dt
                dt_until = av.dt_until
                if dt_until is not None:
                    # minimal consistent change (follower's alteration
                    # could change it further)
                    av.dt_until = max(dt_until, new_dt)
            if follower is not None and new_dt > follower.dt_execution:
                follower.alter_dt_execution(new_dt)
Beispiel #10
0
    def create(cls, input=None, inputs=None, **kwargs):
        """Accept the alternative ``input`` arg and call back the base class.

        This override is for convenience in a case of a single input.
        """
        if input is not None and inputs is not None:
            # not an OperationInputsError, because it's not about the
            # contents of the inputs (one could say they aren't really known
            raise OperationError(
                cls, "You must choose between the 'input' and the 'inputs' "
                "kwargs (got input={input}, inputs={inputs}",
                input=input,
                inputs=inputs)
        if input is not None:
            inputs = (input, )
        return super(WmsSingleInputOperation, cls).create(inputs=inputs,
                                                          **kwargs)
Beispiel #11
0
    def refine_with_trailing_move(self, stopover):
        """Split the current Operation in two, the last one being a Move

        This is for Operations that are responsible for the location of
        their outcome (see the :attr:`destination_field
        <anyblok_wms_base.core.operation.base.Operation.destination_field>`
        class attribute)

        :param stopover: this is the location of the intermediate Avatar
                         that's been introduced (starting point of the Move).
        :returns: the new Move instance

        This doesn't change anything for the followers of the current
        Operation, and in fact, it is guaranteed that their inputs are
        untouched by this method.

        Example use case: Rather than planning an Arrival followed by a Move to
        stock location, One may wish to just plan an Arrival into some
        the final stock destination, and later on, refine
        this as an Arrival in a landing area, followed by a Move to the stock
        destination. This is especially useful if the landing area can't be
        determined at the time of the original planning, or simply to follow
        the general principle of sober planning.
        """
        self.check_alterable()
        field = self.destination_field
        if field is None:
            raise OperationError(
                self,
                "Can't refine {op} with a trailing move, because it's "
                "not responsible for the location of its outcomes",
                op=self)
        setattr(self, field, stopover)

        outcome = self.outcome
        new_outcome = self.registry.Wms.PhysObj.Avatar.insert(
            location=stopover,
            outcome_of=self,
            state='future',
            dt_from=self.dt_execution,
            # copied fields:
            dt_until=outcome.dt_until,
            obj=outcome.obj)
        return self.registry.Wms.Operation.Move.plan_for_outcomes(
            (new_outcome, ), (outcome, ), dt_execution=self.dt_execution)
Beispiel #12
0
    def plan_for_outcomes(cls, inputs, outcomes, dt_execution=None, **fields):
        if len(outcomes) != 1:
            raise OperationError(
                cls, "can't plan for {nb_outcomes} outcomes, would need "
                "exactly one outcome. Got {outcomes!r})",
                nb_outcomes=len(outcomes),
                outcomes=outcomes)

        outcome = next(iter(outcomes))
        destination = outcome.location
        cls.check_create_conditions('planned', dt_execution,
                                    inputs=inputs, destination=destination,
                                    **fields)
        move = cls.insert(destination=destination, state='planned',
                          dt_execution=dt_execution, **fields)
        move.link_inputs(inputs)
        outcome.outcome_of = move
        return move
Beispiel #13
0
    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.goods).filter(
            Avatar.reason == self, Avatar.state != 'past',
            PhysObj.quantity == self.quantity).first()
        if outcome is None:
            raise OperationError(self, "The split outcomes have disappeared")
        return outcome
Beispiel #14
0
    def alter_destination(self, destination):
        """Change the destination of a planned Operation.

        This is for Operations that are responsible for the location of
        their outcomes (see the :attr:`destination_field` class attribute)

        The followers' :meth:`input_location_altered` will be
        called (will potentially recurse)
        """
        self.check_alterable()
        dest_field = self.destination_field
        if dest_field is None:
            raise OperationError(
                self, "Operations of this type don't have responsibility "
                "over their outcomes locations. Fields: {op}",
                op=self)
        setattr(self, dest_field, destination)
        for outcome in self.outcomes:
            outcome.location = destination
        for follower in self.followers:
            follower.input_location_altered()
Beispiel #15
0
    def refine_with_trailing_unpack(cls, arrivals, pack_type,
                                    dt_pack_arrival=None,
                                    dt_unpack=None,
                                    pack_properties=None,
                                    pack_code=None):
        """Replace some Arrivals by the Arrival of a pack followed by an Unpack.

        This is useful in cases where it is impossible to predict ahead how
        incoming goods will actually be packed: the arrivals of individual
        items can first be planned, and once more is known about the form
        of delivery, this classmethod can replace some of them with the
        Arrival of a parcel and the subsequent Unpack.

        Together with :meth:`refine_with_trailing_move
        <anyblok_wms_base.core.operation.base.Operation.refine_with_trailing_move>`,
        this can handle the use case detailed in
        :ref:`improvement_operation_superseding`.

        :param arrivals:
            the Arrivals considered to be superseded by the Unpack.
            It is possible that only a subset of them are superseded, and
            conversely that the Unpack has more outcomes than the superseded
            Arrivals. For more details about the matching, see
            :meth:`Unpack.plan_for_outcomes
            <anyblok_wms_base.core.operation.unpack.Unpack.plan_for_outcomes>`
        :param pack_type:
            :attr:`anyblok_wms_base.core.PhysObj.main.PhysObj.type` of the
            expected pack.
        :param pack_properties:
            optional properties of the expected Pack. This optional parameter
            is of great importance in the case of parcels with variable
            contents, since it allows to set the ``contents`` Property.
        :param str pack_code:
            Optional code of the expected Pack.
        :param datetime dt_pack_arrival:
            expected date/time for the Arrival of the pack. If not specified,
            a default one will be computed.
        :param datetime dt_unpack:
            expected date/time for the Unpack Operation. If not specified,
            a default one will be computed.
        """  # noqa (unbreakable meth crosslink)
        for arr in arrivals:
            arr.check_alterable()
        if not arrivals:
            raise OperationError(cls,
                                 "got empty collection of arrivals "
                                 "to refine: {arrivals!r}",
                                 arrivals=arrivals)
        arr_iter = iter(arrivals)
        location = next(arr_iter).location
        if not all(arr.location == location for arr in arr_iter):
            raise OperationError(cls,
                                 "can't rewrite arrivals to different "
                                 "locations, got {nb_locs} different ones in "
                                 "{arrivals}",
                                 nb_locs=len(set(arr.location
                                                 for arr in arrivals)),
                                 arrivals=arrivals)

        Wms = cls.registry.Wms
        Unpack = Wms.Operation.Unpack
        # check that the arrivals happen in the same locations
        if dt_pack_arrival is None:
            # max minimizes the number of date/time shifts to perform
            # upon later execution, min is more optimistic
            dt_pack_arrival = min(arr.dt_execution for arr in arrivals)
        pack_arr = cls.create(location=location,
                              dt_execution=dt_pack_arrival,
                              physobj_type=pack_type,
                              physobj_properties=pack_properties,
                              physobj_code=pack_code,
                              state='planned')

        arrivals_outcomes = {arr.outcome: arr for arr in arrivals}
        unpack, attached_avatars = Unpack.plan_for_outcomes(
            pack_arr.outcomes,
            arrivals_outcomes.keys(),
            dt_execution=dt_unpack)
        for att in attached_avatars:
            arrivals_outcomes[att].delete()
        return unpack
Beispiel #16
0
    def create(cls,
               state='planned',
               inputs=None,
               dt_execution=None,
               dt_start=None,
               **fields):
        """Main method for creation of operations

        In contrast with :meth:`insert`, this class method performs
        some Wms specific logic,
        e.g, creation of Goods, but that's up to the specific subclasses.

        :param state:
           value of the :attr:`state` field right after creation. It has
           a strong influence on the consequences of the Operation:
           creating an Operation in the ``done`` state means executing it
           right away.

           Creating an Operation in the ``started`` state should
           make the relevant Goods and/or Avatar locked or destroyed right away
           (TODO not implemented)
        :param fields: remaining fields, to be forwarded to ``insert`` and
                       the various involved methods implemented in subclasses.
        :param dt_execution:
           value of the attr:`dt_execution` right at creation. If
           ``state==planned``, this is mandatory. Otherwise, it defaults to
           the current date and time (:meth:`datetime.now`)
        :return Operation: concrete instance of the appropriate Operation
                           subclass.

        In principle, downstream developers should never call :meth:`insert`.

        As this is Python, nothing really forbids them of doing so, but they
        must then exactly know what they are doing, much like issuing
        INSERT statements to the database).

        Keeping :meth:`insert` as in vanilla SQLAlchemy has the advantage of
        making them easily usable in
        ``wms_core`` internal implementation without side effects.

        On the other hand, downstream developers should feel free to
        :meth:`insert` and :meth:`update` in their unit or integration tests.
        The fact that they are inert should help reproduce weird situations
        (yes, the same could be achieved by forcing to use the Model class
        methods instead).
        """
        if dt_execution is None:
            if state == 'done':
                dt_execution = datetime.now()
            else:
                raise OperationError(
                    cls, "Creation in state {state!r} requires the "
                    "'dt_execution' field (date and time when "
                    "it's supposed to be done).",
                    state=state)
        cls.check_create_conditions(state,
                                    dt_execution,
                                    inputs=inputs,
                                    **fields)
        inputs, fields_upd = cls.before_insert(state=state,
                                               inputs=inputs,
                                               dt_execution=dt_execution,
                                               dt_start=dt_start,
                                               **fields)
        if fields_upd is not None:
            fields.update(fields_upd)
        op = cls.insert(state=state, dt_execution=dt_execution, **fields)
        if inputs is not None:  # happens with creative Operations
            op.link_inputs(inputs)
        op.after_insert()
        return op