fix(marketplace): перераскладка склада обратима + штрих-код в строке
Раскладка по полкам теперь перераспределяет ВЕСЬ непромаркированный RECEIVED-пул заказа, а не один кусок: оператор может собрать имущество с полок обратно (merge), разложить иначе (re-split) или перенести — в одном действии «Разложить». Промаркированные единицы (штрих-код) фиксированы и в пул не входят. Backend: splitInventory собирает пул по order_id (непромаркированные RECEIVED), сверяет сумму долей с суммой пула, схлопывает лишние куски (новый repo.deleteById). Сигнатура GraphQL не изменилась — codegen не нужен. Desktop: штрих-код показывается в той же строке, что имущество и полка (нижний печатный лист скрыт на экране, разворачивается только при печати); диалог раскладки предзаполняется текущим раскладом заказа по полкам. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+42
-23
@@ -88,69 +88,88 @@ export class MarketplaceInventoryLabelService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Разложить позицию по нескольким полкам. Разрешено только до маркировки
|
||||
* (RECEIVED): штрих-код относится к конкретной физической единице, дробить
|
||||
* уже промаркированную позицию запрещено. Сумма долей == количество позиции.
|
||||
* Перераскладка непромаркированного остатка заказа по полкам. Операция
|
||||
* перераспределяет ВЕСЬ непромаркированный RECEIVED-пул заказа (а не только
|
||||
* переданную позицию): это позволяет «собрать с полок обратно и разложить
|
||||
* иначе» — merge, повторный split и перенос полок в одном действии.
|
||||
*
|
||||
* Промаркированные куски (штрих-код = конкретная физическая единица) в пул не
|
||||
* входят и не трогаются: дробить/схлопывать промаркированное запрещено.
|
||||
* Сумма долей == суммарное количество непромаркированного пула.
|
||||
*/
|
||||
async splitInventory(
|
||||
input: MarketplaceSplitInventoryInputDto
|
||||
): Promise<MarketplaceInventoryMutationResult> {
|
||||
const item = await this.loadOwned(input.coopname, input.inventory_id);
|
||||
if (item.status !== MarketplaceInventoryStatuses.RECEIVED) {
|
||||
const target = await this.loadOwned(input.coopname, input.inventory_id);
|
||||
if (target.status !== MarketplaceInventoryStatuses.RECEIVED || target.barcode_value) {
|
||||
throw new ConflictException(
|
||||
'Разложить по полкам можно только непромаркированную позицию (на складе, без штрих-кода).'
|
||||
'Перераскладывать можно только непромаркированную позицию (на складе, без штрих-кода).'
|
||||
);
|
||||
}
|
||||
if (!input.splits.length) {
|
||||
throw new BadRequestException('Не указаны доли разбиения.');
|
||||
throw new BadRequestException('Не указаны доли раскладки.');
|
||||
}
|
||||
const quantities = input.splits.map((s) => Math.trunc(s.quantity));
|
||||
if (quantities.some((q) => q <= 0)) {
|
||||
throw new BadRequestException('Количество в каждой доле должно быть положительным целым.');
|
||||
}
|
||||
|
||||
// Пул перераскладки — все непромаркированные RECEIVED-куски того же заказа.
|
||||
const pool = (
|
||||
await this.inventoryRepo.list({
|
||||
coopname: target.coopname,
|
||||
order_id: target.order_id,
|
||||
status: MarketplaceInventoryStatuses.RECEIVED,
|
||||
})
|
||||
).filter((p) => !p.barcode_value);
|
||||
const poolTotal = pool.reduce((a, p) => a + p.quantity_per_label, 0);
|
||||
|
||||
const sum = quantities.reduce((a, q) => a + q, 0);
|
||||
if (sum !== item.quantity_per_label) {
|
||||
if (sum !== poolTotal) {
|
||||
throw new BadRequestException(
|
||||
`Сумма долей (${sum}) не равна количеству позиции (${item.quantity_per_label}).`
|
||||
`Сумма долей (${sum}) не равна количеству позиции (${poolTotal}).`
|
||||
);
|
||||
}
|
||||
|
||||
const result: MarketplaceInventoryDomainEntity[] = [];
|
||||
// Первая доля переиспользует исходную запись (сохраняем её id/историю),
|
||||
// остальные — новые позиции с теми же снапшотами.
|
||||
// target переиспользуем под первую долю (сохраняем id/историю), остальные
|
||||
// куски пула удаляем (схлопываем), затем создаём новые доли с тем же снапшотом.
|
||||
const first = input.splits[0];
|
||||
result.push(
|
||||
await this.inventoryRepo.resize(
|
||||
item.id,
|
||||
target.id,
|
||||
quantities[0],
|
||||
first.shelf?.trim() ? first.shelf.trim() : item.shelf
|
||||
first.shelf?.trim() ? first.shelf.trim() : null
|
||||
)
|
||||
);
|
||||
for (const piece of pool) {
|
||||
if (piece.id !== target.id) await this.inventoryRepo.deleteById(piece.id);
|
||||
}
|
||||
for (let i = 1; i < input.splits.length; i++) {
|
||||
const piece = input.splits[i];
|
||||
const created = await this.inventoryRepo.create({
|
||||
coopname: item.coopname,
|
||||
order_id: item.order_id,
|
||||
shipment_id: item.shipment_id,
|
||||
braname: item.braname,
|
||||
coopname: target.coopname,
|
||||
order_id: target.order_id,
|
||||
shipment_id: target.shipment_id,
|
||||
braname: target.braname,
|
||||
status: MarketplaceInventoryStatuses.RECEIVED,
|
||||
product_name_snapshot: item.product_name_snapshot,
|
||||
product_name_snapshot: target.product_name_snapshot,
|
||||
quantity_per_label: quantities[i],
|
||||
orderer_account_snapshot: item.orderer_account_snapshot,
|
||||
orderer_account_snapshot: target.orderer_account_snapshot,
|
||||
shelf: piece.shelf?.trim() ? piece.shelf.trim() : null,
|
||||
received_at: item.received_at,
|
||||
received_by_operator_account: item.received_by_operator_account,
|
||||
received_at: target.received_at,
|
||||
received_by_operator_account: target.received_by_operator_account,
|
||||
barcode_value: null,
|
||||
barcode_format: null,
|
||||
labeled_at: null,
|
||||
labeled_by_operator_account: null,
|
||||
expiry_date: item.expiry_date,
|
||||
expiry_date: target.expiry_date,
|
||||
});
|
||||
result.push(created);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Inventory: позиция ${item.id} разложена на ${result.length} полок(и) (Order ${item.order_id}).`
|
||||
`Inventory: заказ ${target.order_id} перераскладкой собран из ${pool.length} в ${result.length} полок(и).`
|
||||
);
|
||||
return { inventory: result };
|
||||
}
|
||||
|
||||
+6
@@ -76,4 +76,10 @@ export interface MarketplaceInventoryDomainRepository {
|
||||
quantity_per_label: number,
|
||||
shelf: string | null
|
||||
): Promise<MarketplaceInventoryDomainEntity>;
|
||||
|
||||
/**
|
||||
* Удалить позицию склада. Используется при перераскладке: лишние куски пула
|
||||
* заказа схлопываются в одну запись (собрать с полок обратно).
|
||||
*/
|
||||
deleteById(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
+4
@@ -119,4 +119,8 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
|
||||
const row = await this.repo.findOneOrFail({ where: { id } });
|
||||
return this.mapper.toDomain(row);
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.repo.delete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
+61
-14
@@ -50,6 +50,23 @@ const onWarehouse = computed(() =>
|
||||
|
||||
const labeledItems = computed(() => items.value.filter((i) => !!i.barcode_value))
|
||||
|
||||
// Непромаркированный RECEIVED-пул одного заказа — куски, которые можно собрать
|
||||
// с полок и разложить заново (merge/split/перенос). Промаркированные единицы
|
||||
// (со штрих-кодом) фиксированы и в пул не входят.
|
||||
function orderPool(item: MarketplaceInventoryItemView): MarketplaceInventoryItemView[] {
|
||||
return items.value.filter(
|
||||
(i) => i.order_id === item.order_id && i.status === RECEIVED && !i.barcode_value,
|
||||
)
|
||||
}
|
||||
function orderPoolTotal(item: MarketplaceInventoryItemView): number {
|
||||
return orderPool(item).reduce((a, p) => a + p.quantity_per_label, 0)
|
||||
}
|
||||
// Перераскладка доступна для непромаркированной позиции, если в заказе ≥2 ед.
|
||||
// (есть что распределить или собрать обратно).
|
||||
function canRedistribute(item: MarketplaceInventoryItemView): boolean {
|
||||
return !item.barcode_value && orderPoolTotal(item) >= 2
|
||||
}
|
||||
|
||||
// Черновик полки на строку — чтобы сохранять только по явному действию.
|
||||
const shelfDraft = reactive<Record<string, string>>({})
|
||||
|
||||
@@ -102,21 +119,29 @@ const splitRows = ref<{ quantity: number | null; shelf: string }[]>([])
|
||||
const splitTotal = computed(() =>
|
||||
splitRows.value.reduce((a, r) => a + (Number(r.quantity) || 0), 0),
|
||||
)
|
||||
// Цель раскладки — суммарное количество непромаркированного пула заказа.
|
||||
const splitPoolTotal = computed(() =>
|
||||
splitTarget.value ? orderPoolTotal(splitTarget.value) : 0,
|
||||
)
|
||||
const splitValid = computed(
|
||||
() =>
|
||||
!!splitTarget.value &&
|
||||
splitRows.value.length >= 1 &&
|
||||
splitRows.value.every((r) => Number(r.quantity) > 0) &&
|
||||
splitTotal.value === splitTarget.value.quantity_per_label,
|
||||
splitTotal.value === splitPoolTotal.value,
|
||||
)
|
||||
|
||||
function openSplit(item: MarketplaceInventoryItemView): void {
|
||||
splitTarget.value = item
|
||||
// Предзаполняем двумя долями: текущая полка + пустая.
|
||||
splitRows.value = [
|
||||
{ quantity: item.quantity_per_label, shelf: item.shelf ?? '' },
|
||||
{ quantity: null, shelf: '' },
|
||||
]
|
||||
// Предзаполняем строками из текущих кусков пула (кол-во + полка) — оператор
|
||||
// видит, как заказ сейчас лежит на полках, и правит как нужно. Один кусок —
|
||||
// добавляем пустую строку, чтобы предложить раскладку.
|
||||
const pool = orderPool(item)
|
||||
splitRows.value = pool.map((p) => ({
|
||||
quantity: p.quantity_per_label as number | null,
|
||||
shelf: p.shelf ?? '',
|
||||
}))
|
||||
if (splitRows.value.length === 1) splitRows.value.push({ quantity: null, shelf: '' })
|
||||
splitDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -140,7 +165,11 @@ async function applySplit(): Promise<void> {
|
||||
shelf: r.shelf.trim() || null,
|
||||
})),
|
||||
})
|
||||
SuccessAlert(`Позиция разложена на ${splitRows.value.length} полок(и)`)
|
||||
SuccessAlert(
|
||||
splitRows.value.length > 1
|
||||
? `Заказ разложен на ${splitRows.value.length} полок(и)`
|
||||
: 'Заказ собран на одной полке',
|
||||
)
|
||||
await load()
|
||||
} catch (e) {
|
||||
FailAlert(e, 'Не удалось разложить позицию')
|
||||
@@ -203,6 +232,9 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
| Принято {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
|
||||
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
|
||||
BaseBadge(v-else, variant='neutral') Без штрих-кода
|
||||
//- Штрих-код в той же строке, что и имущество с полкой — не оторван.
|
||||
.place__item-barcode(v-if='item.barcode_value')
|
||||
BarcodeDisplay(:code='item.barcode_value', size='sm')
|
||||
.place__item-controls
|
||||
BaseInput.place__shelf(
|
||||
v-model='shelfDraft[item.id]',
|
||||
@@ -213,9 +245,10 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
@keydown.enter='saveShelf(item)'
|
||||
)
|
||||
BaseButton(
|
||||
v-if='!item.barcode_value',
|
||||
variant='ghost',
|
||||
size='sm',
|
||||
:disabled='!!item.barcode_value || item.quantity_per_label < 2',
|
||||
:disabled='!canRedistribute(item)',
|
||||
@click='openSplit(item)'
|
||||
)
|
||||
template(#icon-left)
|
||||
@@ -231,8 +264,8 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
q-icon(name='qr_code_2', size='16px')
|
||||
| Этикетка
|
||||
|
||||
//- Печатная раскладка этикеток — только промаркированные позиции.
|
||||
.place__grid(v-if='labeledItems.length')
|
||||
//- Печатный лист этикеток (только при печати; на экране штрих-код в строке).
|
||||
.place__grid.print-only(v-if='labeledItems.length')
|
||||
.place__label(v-for='item in labeledItems', :key='item.id')
|
||||
.place__label-name.ellipsis {{ item.product_name_snapshot }}
|
||||
.place__label-meta.ellipsis
|
||||
@@ -243,7 +276,10 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
BaseDialog(v-model='splitDialogOpen', title='Разложить по полкам', size='md')
|
||||
.place__split(v-if='splitTarget')
|
||||
.place__split-head
|
||||
| {{ splitTarget.product_name_snapshot || 'Товар' }} — всего {{ splitTarget.quantity_per_label }} ед.
|
||||
| {{ splitTarget.product_name_snapshot || 'Товар' }} — всего {{ splitPoolTotal }} ед.
|
||||
.place__split-note
|
||||
| Распределите весь заказ по полкам. Чтобы собрать обратно на одну полку —
|
||||
| удалите лишние строки; чтобы разложить иначе — измените количества и полки.
|
||||
.place__split-row(v-for='(row, idx) in splitRows', :key='idx')
|
||||
BaseInput.place__split-qty(
|
||||
v-model.number='row.quantity',
|
||||
@@ -272,8 +308,8 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
template(#icon-left)
|
||||
q-icon(name='add', size='16px')
|
||||
| Ещё полка
|
||||
span.place__split-total(:class='{ "place__split-total--bad": splitTotal !== splitTarget.quantity_per_label }')
|
||||
| Сумма: {{ splitTotal }} / {{ splitTarget.quantity_per_label }}
|
||||
span.place__split-total(:class='{ "place__split-total--bad": splitTotal !== splitPoolTotal }')
|
||||
| Сумма: {{ splitTotal }} / {{ splitPoolTotal }}
|
||||
template(#footer)
|
||||
BaseButton(variant='ghost', size='sm', @click='splitDialogOpen = false') Отмена
|
||||
BaseButton(variant='primary', size='sm', :disabled='!splitValid', @click='applySplit') Разложить
|
||||
@@ -346,6 +382,15 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
color: var(--p-ink);
|
||||
}
|
||||
|
||||
&__split-note {
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
&__item-barcode {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__split-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -377,8 +422,9 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
}
|
||||
}
|
||||
|
||||
// Печатный лист этикеток — скрыт на экране, разворачивается только при печати.
|
||||
&__grid {
|
||||
display: grid;
|
||||
display: none;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--p-4, 16px);
|
||||
}
|
||||
@@ -416,6 +462,7 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
|
||||
padding: 0;
|
||||
}
|
||||
.place__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4mm;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user