handbookНаписать

patterns/behavioral/state/practice-production/README.md · практика · 4 языка · 59 тестов

Практика: плата на производстве (Production line) ​

Печатная плата идёт по этапам монтаж -> контроль -> прошивка -> упаковка, а сканеры на участках присылают одни и те же четыре действия: что значит "передать дальше" или "вернуть на доработку", решает этап, на котором плата сейчас.

Базовый пример: Состояние. Там у плеера три режима и кнопки, которые в неподходящем режиме молча ничего не делают. Здесь кнопок тоже немного, но каждое действие сканера - либо переход с записью в историю, либо отказ с причиной, у каждого этапа есть своё подсостояние "ждёт" или "в работе", а одно правило зависит от прошлого платы: сколько раз её уже возвращали.

Задача ​

Контрактное производство собирает платы для заказчиков. Плата проходит четыре участка: монтаж компонентов, визуальный контроль, прошивка, упаковка. На каждом участке оператор сканирует штрихкод платы и жмёт одну из четырёх кнопок терминала: "принять" (взял в работу), "передать дальше" (закончил), "отбраковать" и "вернуть на доработку". Сканер присылает в систему только серийный номер и действие.

Сначала статус платы был полем, а проверки жили в обработчике сканера: if status == 'inspection' and action == 'rework'. Потом выяснилось, что операторы передают дальше платы, которые не принимали, и в отчёте участка пропадает время работы. Технолог запретил возвращать на доработку больше двух раз: после третьей перепайки плата идёт в брак, потому что дорожки отслаиваются от нагрева. Заказчик попросил по каждой плате историю: кто и куда её двигал. Каждое правило добавляло if в обработчик, и разобраться, что можно делать с платой на прошивке, стало невозможно без отладчика.

Отличие от первой практики: там у каждого перехода своё событие (pay, ship), и вопрос в том, разрешено ли оно. Здесь действий четыре на все этапы, и вопрос в том, куда ведёт одно и то же действие с этого этапа.

Что урезано ​

  • Нет операторов, участков и времени: в истории только "откуда, действие, куда". В проекте к переходу добавляют табельный номер и время, форма та же.
  • Нет хранилища: платы живут в памяти, восстановление из базы показано в первой практике.
  • Причина брака не передаётся, отбраковка - одно действие без кода дефекта.
  • Сканер - строка "серийный номер и действие", без протокола терминала.

Решение ​

Этап платы - объект-состояние. Базовое BoardState запрещает все четыре действия (accept, pass, reject, rework) и бросает IllegalScan с текстом board B-7: cannot rework at flashing. Конечные этапы "отгружена" и "брак" - это EndStage: он ничего не переопределяет, поэтому из него запрещено всё.

Четыре этапа линии ведут себя одинаково в главном: плату надо принять, прежде чем передать дальше, и отбраковать её можно с любого этапа. Это общее поведение живёт в LineStage вместе с флагом inWork - вложенным состоянием "ждёт / в работе". Конкретные этапы Assembly, Inspection, Flashing, Packing говорят только, какой этап следующий (next). Inspection единственный разрешает rework и сам решает, куда вернуть плату: на монтаж или, если лимит доработок исчерпан, в брак.

Board - контекст. Он превращает строку сканера в действие, отдаёт его текущему этапу и записывает Transition в историю. Если этап отказал, исключение вылетает до записи: история и этап не меняются. Число доработок не хранится отдельным полем: reworks() считает их по истории, и правило лимита читает то же, что видит заказчик.

Структура ​

Board

+serial: string

-state: BoardState

-history: Transition[]

+scan(code)

+status() : string

+history() : Transition[]

+reworks() : int

«abstract»

BoardState

+stage() : Stage

+label() : string

+accept(board) : BoardState

+pass(board) : BoardState

+reject(board) : BoardState

+rework(board) : BoardState

«abstract»

LineStage

-inWork: bool

#next() : BoardState

Assembly

Inspection

+rework(board) : BoardState

Flashing

Packing

EndStage

-stage: Stage

Transition

+from: string

+action: Action

+to: string

accept, pass

accept, pass

accept, rework

rework после двух доработок

accept, pass

accept, pass

reject

reject

reject

reject

assembly

inspection

flashing

scrapped

packing

shipped

На диаграмме переходов accept не виден как отдельная стрелка: он не меняет этап, а переводит плату из "ждёт" в "в работе" внутри этапа. В истории такой переход есть: inspection -accept-> inspection (in work).

Вложенное состояние и правило по истории ​

Флаг inWork можно было бы сделать ещё четырьмя классами (AssemblyInWork и так далее), но тогда правила "принять дважды нельзя" и "передать без приёма нельзя" повторились бы в восьми местах. Это классическая развилка State: когда у всех состояний есть общее подсостояние, его держит общий родитель, а состояния-наследники описывают только то, чем они различаются. Принятая плата - тот же класс этапа с inWork = true, поэтому Inspection в работе по-прежнему умеет rework.

Лимит доработок - правило, которое зависит не от текущего этапа, а от пути платы. Состояние получает Board параметром и спрашивает reworks(); поле-счётчик рядом с историей легко разошлось бы с ней, а история уже есть. Третья доработка всё равно записывается в историю, но ведёт в scrapped: по истории видно, почему плата ушла в брак.

Как это собирается ​

Плата создаётся, когда партию выпускают на линию: система заводит Board на каждый серийный номер, все стоят на монтаже и ждут. Дальше сервер терминалов получает строки от сканеров в порядке прихода, находит плату по серийному номеру и зовёт scan с кодом действия. Сервер не знает правил этапов: он только показывает оператору отказ красным на терминале, если scan бросил ошибку.

В примере две платы за смену. B-1 проходит весь маршрут, по дороге оператор прошивки по ошибке жмёт "вернуть на доработку" и получает отказ - доработка разрешена только с контроля. B-2 с контроля возвращается на монтаж, потом кто-то сканирует несуществующее действие skip, и в конце плату отбраковывают. В выводе отказы, итог по обеим платам и история B-2.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Состояния неизменяемы: accept возвращает новый объект того же этапа с inWork = true, а не меняет флаг у текущего. Тексты отказов, лимит в две доработки и формат истории одинаковы во всех языках.

ЯзыкОсобенность
PHPenum Stage и enum Action, Action::tryFrom для строки сканера; абстрактные BoardState и LineStage, new static(true) при final конструкторе; match по действию исчерпывающий
GoНаследования нет: lineStage - одна структура, а различия этапов приходят функциями next и rework; конструкторы assembly(), inspection() собирают этапы; ошибка - *ScanError для errors.As
PythonStrEnum, базовый класс с запретами, LineStage - frozen dataclass, replace(self, in_work=True) сохраняет класс наследника; метод pass_, потому что pass - ключевое слово
TypeScriptСостояния - объекты из фабрик; тип Record<Action, ...> делает имена методов равными действиям, и Board вызывает this.state[code](this) без switch

Запуск: make test T=patterns/behavioral/state/practice-production

Что проверяют тесты ​

Тесты одинаковы во всех языках, маршрут платы - строка действий, как в базовом примере.

  • Куда ведёт действие. Семь маршрутов: новая плата ждёт на монтаже, accept берёт её в работу, accept pass ведёт на контроль, полный маршрут заканчивается shipped, доработка возвращает на монтаж, reject с прошивки ведёт в брак, третья доработка - тоже в брак.
  • Отказы. Пять случаев с точным текстом: передать без приёма, принять дважды, доработка на прошивке, любое действие после брака и после отгрузки. В каждом этап и история после отказа те же, что до него.
  • История. После accept pass accept rework в ней ровно четыре перехода с подсостоянием "в работе".
  • Неизвестное действие сканера падает с ошибкой, в которой есть сам код 'skip'.

Где граница ​

  • Этапы различаются только тем, какой следующий, - это таблица маршрута, и классы этапов ей не нужны. State окупается здесь на Inspection и EndStage: у них своё поведение. Если маршрут разный для разных изделий (у одной платы нет прошивки, у другой два контроля), маршрут уезжает в данные, а состояниями остаются "ждёт", "в работе", "конец".
  • Нужны параллельные операции (прошивка и лазерная маркировка одновременно) - один объект-состояние их не выразит, это уже иерархический автомат или workflow-движок.
  • Правил по истории становится много (лимиты по участкам, по времени, по оператору) - их выносят в отдельные проверки перед переходом, а состояние только спрашивает их.
  • Если действие на переходе должно что-то сделать снаружи (напечатать этикетку, списать компоненты), это ложится в метод состояния, как деньги в первой практике.

Отличия между языками ​

  • PHP - два уровня абстрактных классов: BoardState даёт запреты, LineStage - приём и передачу. new static(true) создаёт принятую копию того же этапа; PHPStan пропускает его только при final конструкторе. Action::tryFrom возвращает null на незнакомую строку, и ?? throw превращает это в понятную ошибку.
  • Go - наследования нет, и вместо четырёх структур с одинаковыми Accept и Pass одна lineStage с полями-функциями: next и rework (nil значит "нельзя"). Это всё ещё State: контекст держит интерфейс State и делегирует ему, а значение lineStage с inWork = true копируется, а не меняется.
  • Python - LineStage - frozen dataclass, наследники задают stage как ClassVar и не объявляют своих полей, поэтому dataclasses.replace создаёт объект того же класса. match по StrEnum выбирает метод; pass_ с подчёркиванием по PEP 8.
  • TypeScript - этап - объект с методами accept, pass, reject, rework, а тип Record<Action, ...> гарантирует, что метод есть на каждое действие. После проверки isAction строка сканера становится ключом, и диспетчеризация - одна строка this.state[code](this).

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS