Files
esun3d/app/code/Esun/CustomerMessage/Model/ConversationService.php
T
2026-09-15 10:35:56 +08:00

346 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\CustomerMessage\Model;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\OrderRepositoryInterface;
class ConversationService
{
public function __construct(
private readonly ResourceConnection $resource,
private readonly OrderRepositoryInterface $orders,
private readonly AttachmentStorage $attachments
) {
}
public function getThreadsForCustomer(int $customerId, int $limit = 100): array
{
$connection = $this->resource->getConnection();
return $connection->fetchAll(
$connection->select()->from(['thread' => $this->table('esun_customer_message_thread')])
->joinLeft(['entry' => $this->table('esun_customer_message_entry')], 'entry.entry_id=thread.last_message_id', ['last_content' => 'content', 'last_sender_type' => 'sender_type'])
->joinLeft(['orders' => $this->table('sales_order')], 'orders.entity_id=thread.order_id', ['order_increment_id' => 'increment_id'])
->where('thread.customer_id=?', $customerId)
->order('thread.last_message_at DESC')
->limit(max(1, min(100, $limit)))
);
}
public function getCustomerUnreadTotal(int $customerId): int
{
$connection = $this->resource->getConnection();
return (int)$connection->fetchOne(
$connection->select()->from($this->table('esun_customer_message_thread'), ['total' => new \Zend_Db_Expr('COALESCE(SUM(customer_unread_count), 0)')])
->where('customer_id=?', $customerId)
);
}
/**
* Return unread message totals keyed by order entity ID.
*
* @param int[] $orderIds
* @return array<int, int>
*/
public function getCustomerUnreadByOrderIds(int $customerId, array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if ($customerId <= 0 || !$orderIds) {
return [];
}
$connection = $this->resource->getConnection();
$rows = $connection->fetchPairs(
$connection->select()
->from(
$this->table('esun_customer_message_thread'),
['order_id', 'customer_unread_count']
)
->where('customer_id=?', $customerId)
->where('order_id IN (?)', $orderIds)
->where('customer_unread_count > ?', 0)
);
$counts = [];
foreach ($rows as $orderId => $count) {
$counts[(int)$orderId] = (int)$count;
}
return $counts;
}
/**
* Return administrator unread message totals keyed by order entity ID.
*
* @param int[] $orderIds
* @return array<int, int>
*/
public function getAdminUnreadByOrderIds(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
$connection = $this->resource->getConnection();
$rows = $connection->fetchPairs(
$connection->select()
->from(
$this->table('esun_customer_message_thread'),
['order_id', 'admin_unread_count']
)
->where('order_id IN (?)', $orderIds)
->where('admin_unread_count > ?', 0)
);
$counts = [];
foreach ($rows as $orderId => $count) {
$counts[(int)$orderId] = (int)$count;
}
return $counts;
}
public function getThreadsForAdmin(int $limit = 200): array
{
$connection = $this->resource->getConnection();
return $connection->fetchAll(
$connection->select()->from(['thread' => $this->table('esun_customer_message_thread')])
->joinInner(['customer' => $this->table('customer_entity')], 'customer.entity_id=thread.customer_id', ['customer_email' => 'email', 'customer_firstname' => 'firstname', 'customer_lastname' => 'lastname'])
->joinLeft(['entry' => $this->table('esun_customer_message_entry')], 'entry.entry_id=thread.last_message_id', ['last_content' => 'content'])
->joinLeft(['orders' => $this->table('sales_order')], 'orders.entity_id=thread.order_id', ['order_increment_id' => 'increment_id'])
->order('thread.admin_unread_count DESC')
->order('thread.last_message_at DESC')
->limit(max(1, min(500, $limit)))
);
}
public function getThreadForCustomer(int $threadId, int $customerId): ?array
{
return $this->fetchThread(['thread.thread_id=?' => $threadId, 'thread.customer_id=?' => $customerId]);
}
public function getThreadForAdmin(int $threadId): ?array
{
return $this->fetchThread(['thread.thread_id=?' => $threadId]);
}
public function getOrderThreadForCustomer(int $orderId, int $customerId): ?array
{
return $this->fetchThread(['thread.order_id=?' => $orderId, 'thread.customer_id=?' => $customerId]);
}
public function getOrderThreadForAdmin(int $orderId): ?array
{
return $this->fetchThread(['thread.order_id=?' => $orderId]);
}
public function getMessages(int $threadId): array
{
$connection = $this->resource->getConnection();
$messages = $connection->fetchAll(
$connection->select()->from($this->table('esun_customer_message_entry'))
->where('thread_id=?', $threadId)->order('entry_id ASC')
);
if (!$messages) {
return [];
}
$entryIds = array_column($messages, 'entry_id');
$attachmentRows = $connection->fetchAll(
$connection->select()->from($this->table('esun_customer_message_attachment'))
->where('entry_id IN (?)', $entryIds)->order('attachment_id ASC')
);
$attachments = [];
foreach ($attachmentRows as $attachment) {
$attachments[(int)$attachment['entry_id']][] = $attachment;
}
foreach ($messages as &$message) {
$message['attachments'] = $attachments[(int)$message['entry_id']] ?? [];
}
unset($message);
return $messages;
}
public function sendAsCustomer(int $customerId, int $storeId, string $content, array $files, int $threadId = 0, int $orderId = 0, string $subject = ''): int
{
$content = mb_substr(trim($content), 0, 10000);
if ($content === '' && !$this->attachments->hasUploads($files)) {
throw new LocalizedException(__('Please enter a message or upload an image.'));
}
$createdThread = false;
if ($threadId) {
$thread = $this->getThreadForCustomer($threadId, $customerId);
if (!$thread) {
throw new LocalizedException(__('The requested conversation is not available.'));
}
} elseif ($orderId) {
$order = $this->orders->get($orderId);
if ((int)$order->getCustomerId() !== $customerId) {
throw new LocalizedException(__('The requested order is not available.'));
}
$thread = $this->getOrderThreadForCustomer($orderId, $customerId);
if (!$thread) {
$threadId = $this->createThread('order', $customerId, $storeId, (string)__('Order #%1', $order->getIncrementId()), $orderId);
$createdThread = true;
} else {
$threadId = (int)$thread['thread_id'];
}
} else {
$subject = mb_substr(trim($subject), 0, 255);
if ($subject === '') {
throw new LocalizedException(__('Please enter a subject.'));
}
$threadId = $this->createThread('general', $customerId, $storeId, $subject, null);
$createdThread = true;
}
try {
return $this->append($threadId, 'customer', $customerId, $content, $files);
} catch (\Throwable $exception) {
if ($createdThread) {
$this->resource->getConnection()->delete($this->table('esun_customer_message_thread'), ['thread_id=?' => $threadId]);
}
throw $exception;
}
}
public function sendAsAdmin(int $adminId, string $content, array $files, int $threadId = 0, int $orderId = 0): int
{
$content = mb_substr(trim($content), 0, 10000);
if ($content === '' && !$this->attachments->hasUploads($files)) {
throw new LocalizedException(__('Please enter a message or upload an image.'));
}
$createdThread = false;
if ($threadId) {
$thread = $this->getThreadForAdmin($threadId);
if (!$thread) {
throw new LocalizedException(__('The requested conversation is not available.'));
}
} elseif ($orderId) {
$order = $this->orders->get($orderId);
if (!(int)$order->getCustomerId()) {
throw new LocalizedException(__('This order is not associated with a customer account.'));
}
$thread = $this->getOrderThreadForAdmin($orderId);
if ($thread) {
$threadId = (int)$thread['thread_id'];
} else {
$threadId = $this->createThread('order', (int)$order->getCustomerId(), (int)$order->getStoreId(), (string)__('Order #%1', $order->getIncrementId()), $orderId);
$createdThread = true;
}
} else {
throw new LocalizedException(__('An order or conversation is required.'));
}
try {
return $this->append($threadId, 'admin', $adminId, $content, $files);
} catch (\Throwable $exception) {
if ($createdThread) {
$this->resource->getConnection()->delete($this->table('esun_customer_message_thread'), ['thread_id=?' => $threadId]);
}
throw $exception;
}
}
public function markCustomerRead(int $threadId, int $customerId): void
{
$this->resource->getConnection()->update($this->table('esun_customer_message_thread'), ['customer_unread_count' => 0], ['thread_id=?' => $threadId, 'customer_id=?' => $customerId]);
}
public function markAdminRead(int $threadId): void
{
$this->resource->getConnection()->update($this->table('esun_customer_message_thread'), ['admin_unread_count' => 0], ['thread_id=?' => $threadId]);
}
public function getAttachmentForCustomer(int $attachmentId, int $customerId): ?array
{
return $this->fetchAttachment($attachmentId, $customerId);
}
public function getAttachmentForAdmin(int $attachmentId): ?array
{
return $this->fetchAttachment($attachmentId, null);
}
private function createThread(string $type, int $customerId, int $storeId, string $subject, ?int $orderId): int
{
$connection = $this->resource->getConnection();
$connection->insert($this->table('esun_customer_message_thread'), [
'thread_type' => $type,
'customer_id' => $customerId,
'order_id' => $orderId,
'store_id' => $storeId,
'subject' => (string)$subject,
]);
return (int)$connection->lastInsertId();
}
private function append(int $threadId, string $senderType, int $senderId, string $content, array $files): int
{
$connection = $this->resource->getConnection();
$savedPaths = [];
$connection->beginTransaction();
try {
$connection->insert($this->table('esun_customer_message_entry'), [
'thread_id' => $threadId,
'sender_type' => $senderType,
'sender_id' => $senderId,
'content' => $content,
]);
$entryId = (int)$connection->lastInsertId();
$saved = $this->attachments->saveUploads($files, $threadId, $entryId);
$savedPaths = array_column($saved, 'storage_path');
foreach ($saved as $attachment) {
$connection->insert($this->table('esun_customer_message_attachment'), ['entry_id' => $entryId] + $attachment);
}
$now = gmdate('Y-m-d H:i:s');
$data = [
'last_message_id' => $entryId,
'last_message_at' => $now,
'status' => $senderType === 'customer' ? 'awaiting_admin' : 'awaiting_customer',
];
$data[$senderType === 'customer' ? 'admin_unread_count' : 'customer_unread_count'] = new \Zend_Db_Expr(($senderType === 'customer' ? 'admin_unread_count' : 'customer_unread_count') . '+1');
$data[$senderType === 'customer' ? 'customer_unread_count' : 'admin_unread_count'] = 0;
$connection->update($this->table('esun_customer_message_thread'), $data, ['thread_id=?' => $threadId]);
$connection->commit();
return $threadId;
} catch (\Throwable $exception) {
$connection->rollBack();
$this->attachments->delete($savedPaths);
throw $exception;
}
}
private function fetchThread(array $conditions): ?array
{
$connection = $this->resource->getConnection();
$select = $connection->select()->from(['thread' => $this->table('esun_customer_message_thread')])
->joinInner(['customer' => $this->table('customer_entity')], 'customer.entity_id=thread.customer_id', ['customer_email' => 'email', 'customer_firstname' => 'firstname', 'customer_lastname' => 'lastname'])
->joinLeft(['orders' => $this->table('sales_order')], 'orders.entity_id=thread.order_id', ['order_increment_id' => 'increment_id']);
foreach ($conditions as $condition => $value) {
$select->where($condition, $value);
}
$row = $connection->fetchRow($select);
return $row ?: null;
}
private function fetchAttachment(int $attachmentId, ?int $customerId): ?array
{
$connection = $this->resource->getConnection();
$select = $connection->select()->from(['attachment' => $this->table('esun_customer_message_attachment')])
->joinInner(['entry' => $this->table('esun_customer_message_entry')], 'entry.entry_id=attachment.entry_id', [])
->joinInner(['thread' => $this->table('esun_customer_message_thread')], 'thread.thread_id=entry.thread_id', ['thread_id', 'customer_id']);
$select->where('attachment.attachment_id=?', $attachmentId);
if ($customerId !== null) {
$select->where('thread.customer_id=?', $customerId);
}
$row = $connection->fetchRow($select);
return $row ?: null;
}
private function table(string $name): string
{
return $this->resource->getTableName($name);
}
}