EnterpriseCommands.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\service;
  4. use app\miniapi\http\Fields;
  5. use app\miniapi\http\Input;
  6. use app\miniapi\exception\ApiException;
  7. use plugin\saiadmin\app\model\water\WaterEnterprise;
  8. use plugin\saiadmin\app\model\water\WaterEnterpriseAddress;
  9. use plugin\saiadmin\app\model\water\WaterSku;
  10. use plugin\saiadmin\app\model\water\WaterDelivery;
  11. use plugin\saiadmin\app\service\WaterBusiness;
  12. final class EnterpriseCommands
  13. {
  14. public function __construct(private readonly Access $access)
  15. {
  16. }
  17. public function address(int $enterprise, array $input): array
  18. {
  19. $this->access->enterprise($enterprise, true);
  20. Fields::only($input, [
  21. 'address',
  22. 'contact',
  23. 'phone',
  24. 'revision',
  25. 'request_key',
  26. ]);
  27. $address = Fields::text($input, 'address', 255);
  28. $contact = Fields::text($input, 'contact', 60);
  29. $phone = Fields::phone($input);
  30. $revision = Fields::text($input, 'revision', 64);
  31. return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'address:' . $enterprise, $input, function () use ($enterprise, $address, $contact, $phone, $revision) {
  32. $this->access->enterprise($enterprise, true);
  33. $enterpriseRow = WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->firstOrFail();
  34. if (!hash_equals(WaterPresenter::revision($enterpriseRow), $revision)) {
  35. throw new ApiException(40901, '企业资料已更新,请刷新', 409);
  36. }
  37. // Booking uses the same enterprise lock. A locking read sees tasks committed after
  38. // the earlier authorization snapshot, even under MySQL REPEATABLE READ.
  39. $activeTask = WaterDelivery::query()->where('enterprise_id', $enterprise)
  40. ->whereNotNull('active_key')->lockForUpdate()->first(['id']);
  41. if ($activeTask) {
  42. throw new ApiException(40902, '存在未完成配送,请完成或取消后修改配送地址', 409);
  43. }
  44. $values = [
  45. 'address' => $address,
  46. 'contact' => $contact,
  47. 'phone' => $phone,
  48. 'updated_at' => date('Y-m-d H:i:s'),
  49. ];
  50. if ($enterpriseRow->address !== $address) {
  51. $values += [
  52. 'longitude' => null,
  53. 'latitude' => null,
  54. ];
  55. }
  56. $enterpriseRow->fill($values)->save();
  57. $default = WaterEnterpriseAddress::query()->where('enterprise_id', $enterprise)->where('is_default', 1)->where('status', 1)->lockForUpdate()->first();
  58. if ($default) {
  59. $default->fill([
  60. 'address' => $values['address'], 'contact' => $values['contact'], 'phone' => $values['phone'],
  61. 'longitude' => $values['longitude'] ?? $default->longitude, 'latitude' => $values['latitude'] ?? $default->latitude,
  62. 'updated_at' => $values['updated_at'],
  63. ])->save();
  64. }
  65. return [
  66. 'id' => $enterprise,
  67. 'revision' => WaterPresenter::revision($enterpriseRow),
  68. ];
  69. }, fn () => $this->access->enterprise($enterprise, true));
  70. }
  71. private function addressValues(array $input): array
  72. {
  73. Fields::only($input, [
  74. 'label', 'address', 'contact', 'phone', 'region', 'longitude', 'latitude',
  75. 'is_default', 'revision', 'request_key',
  76. ]);
  77. $coordinates = null;
  78. if (array_key_exists('longitude', $input) || array_key_exists('latitude', $input)) {
  79. if (!array_key_exists('longitude', $input) || !array_key_exists('latitude', $input)) {
  80. throw new ApiException(42201, '经纬度必须同时填写', 422);
  81. }
  82. $coordinates = Fields::location($input);
  83. }
  84. return [
  85. 'label' => Fields::text($input, 'label', 80, false) ?: '配送地址',
  86. 'address' => Fields::text($input, 'address', 255),
  87. 'contact' => Fields::text($input, 'contact', 60),
  88. 'phone' => Fields::phone($input),
  89. 'region' => Fields::text($input, 'region', 80, false),
  90. 'longitude' => $coordinates['longitude'] ?? null,
  91. 'latitude' => $coordinates['latitude'] ?? null,
  92. 'is_default' => filter_var($input['is_default'] ?? false, FILTER_VALIDATE_BOOLEAN),
  93. 'revision' => Fields::text($input, 'revision', 64, false),
  94. ];
  95. }
  96. public function createAddress(int $enterprise, array $input): array
  97. {
  98. $this->access->enterprise($enterprise, true);
  99. Fields::only($input, [
  100. 'label', 'address', 'contact', 'phone', 'region', 'longitude', 'latitude',
  101. 'is_default', 'request_key',
  102. ]);
  103. $values = $this->addressValues($input);
  104. $requestKey = Fields::text($input, 'request_key', 80);
  105. return Commands::run($this->access->actor, $requestKey, 'address:create:' . $enterprise, $input, function () use ($enterprise, $values) {
  106. $enterpriseRow = WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->firstOrFail();
  107. $hasDefault = WaterEnterpriseAddress::query()->where('enterprise_id', $enterprise)->where('is_default', 1)->where('status', 1)->exists();
  108. $makeDefault = !$hasDefault || $values['is_default'];
  109. if ($makeDefault) {
  110. WaterEnterpriseAddress::query()->where('enterprise_id', $enterprise)->update(['is_default' => 0]);
  111. }
  112. $now = date('Y-m-d H:i:s');
  113. $id = WaterEnterpriseAddress::query()->insertGetId([
  114. 'enterprise_id' => $enterprise,
  115. 'label' => $values['label'],
  116. 'contact' => $values['contact'],
  117. 'phone' => $values['phone'],
  118. 'region' => $values['region'],
  119. 'address' => $values['address'],
  120. 'longitude' => $values['longitude'],
  121. 'latitude' => $values['latitude'],
  122. 'is_default' => $makeDefault ? 1 : 0,
  123. 'status' => 1,
  124. 'created_at' => $now,
  125. 'updated_at' => $now,
  126. ]);
  127. if ($makeDefault) {
  128. $enterpriseRow->fill([
  129. 'address' => $values['address'], 'contact' => $values['contact'], 'phone' => $values['phone'],
  130. 'region' => $values['region'], 'longitude' => $values['longitude'], 'latitude' => $values['latitude'],
  131. 'updated_at' => $now,
  132. ])->save();
  133. }
  134. return ['id' => (int) $id, 'is_default' => $makeDefault];
  135. }, fn () => $this->access->enterprise($enterprise, true));
  136. }
  137. public function updateAddress(int $enterprise, int $address, array $input): array
  138. {
  139. $this->access->enterprise($enterprise, true);
  140. $values = $this->addressValues($input);
  141. $requestKey = Fields::text($input, 'request_key', 80);
  142. return Commands::run($this->access->actor, $requestKey, 'address:update:' . $address, $input, function () use ($enterprise, $address, $values) {
  143. $enterpriseRow = WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->firstOrFail();
  144. $row = WaterEnterpriseAddress::query()->where('enterprise_id', $enterprise)->where('status', 1)->lockForUpdate()->find($address);
  145. if (!$row) throw new ApiException(40401, '配送地址不存在或已停用', 404);
  146. if ($values['revision'] === '' || !hash_equals(EnterpriseAddressPresenter::revision($row), $values['revision'])) {
  147. throw new ApiException(40901, '配送地址已更新,请刷新', 409);
  148. }
  149. if (WaterDelivery::query()->where('address_id', $address)->whereNotNull('active_key')->exists()
  150. && ($row->address !== $values['address'] || $row->longitude != $values['longitude'] || $row->latitude != $values['latitude'])) {
  151. throw new ApiException(40902, '该地址存在未完成配送,请完成或取消后修改位置', 409);
  152. }
  153. $makeDefault = $values['is_default'] || (int) $row->is_default === 1;
  154. if ($makeDefault) WaterEnterpriseAddress::query()->where('enterprise_id', $enterprise)->update(['is_default' => 0]);
  155. $now = date('Y-m-d H:i:s');
  156. $row->fill([
  157. 'label' => $values['label'], 'address' => $values['address'], 'contact' => $values['contact'],
  158. 'phone' => $values['phone'], 'region' => $values['region'], 'longitude' => $values['longitude'],
  159. 'latitude' => $values['latitude'], 'is_default' => $makeDefault ? 1 : 0, 'updated_at' => $now,
  160. ])->save();
  161. if ($makeDefault) {
  162. $enterpriseRow->fill([
  163. 'address' => $values['address'], 'contact' => $values['contact'], 'phone' => $values['phone'],
  164. 'region' => $values['region'], 'longitude' => $values['longitude'], 'latitude' => $values['latitude'],
  165. 'updated_at' => $now,
  166. ])->save();
  167. }
  168. return ['id' => $address, 'revision' => EnterpriseAddressPresenter::revision($row), 'is_default' => $makeDefault];
  169. }, fn () => $this->access->enterprise($enterprise, true));
  170. }
  171. public function book(int $enterprise, array $input): array
  172. {
  173. $this->access->enterprise($enterprise, true);
  174. Fields::only($input, ['sku_id', 'quantity', 'address_id', 'request_key']);
  175. $sku = Fields::id($input, 'sku_id');
  176. $quantity = Input::integer($input['quantity'] ?? null, 'quantity', 1, 10000);
  177. $address = Input::integer($input['address_id'] ?? 0, 'address_id', 0, 10000000);
  178. return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'delivery.book:' . $enterprise, $input, function () use ($enterprise, $sku, $quantity, $address) {
  179. // Match the existing SKU -> enterprise -> account lock order.
  180. WaterSku::query()->where('id', $sku)->lockForUpdate()->first();
  181. WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->first();
  182. $this->access->enterprise($enterprise, true);
  183. return (new WaterBusiness())->createDelivery([
  184. 'enterprise_id' => $enterprise,
  185. 'sku_id' => $sku,
  186. 'quantity' => $quantity,
  187. 'address_id' => $address,
  188. ], 0);
  189. }, fn () => $this->access->enterprise($enterprise, true));
  190. }
  191. public function cancel(int $enterprise, int $id, array $input): array
  192. {
  193. $this->access->enterprise($enterprise, true);
  194. $this->access->enterpriseTask($enterprise, $id);
  195. Fields::only($input, ['request_key']);
  196. return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'delivery.cancel:' . $id, $input, function () use ($enterprise, $id) {
  197. $this->access->enterprise($enterprise, true);
  198. $task = $this->access->enterpriseTask($enterprise, $id);
  199. WaterSku::query()->where('id', $task->sku_id)->lockForUpdate()->first();
  200. $locked = WaterDelivery::query()->where('id', $id)->lockForUpdate()->firstOrFail();
  201. // Customers may cancel only unassigned requests; dispatched work requires operator handling.
  202. if (!in_array($locked->status, ['pending', 'cancelled'], true)) {
  203. throw new ApiException(40902, '已派单的配送请联系客服取消', 409);
  204. }
  205. return (new WaterBusiness())->transition([
  206. 'id' => $id,
  207. 'action' => 'cancel',
  208. ], 0);
  209. }, fn () => $this->access->enterprise($enterprise, true));
  210. }
  211. }