refaktoryzacja service api.

wydzielenie mockow.
dodanie parsowania wyszukiwania
This commit is contained in:
2026-05-02 05:49:24 +02:00
parent 045c65c363
commit 93778065ce
20 changed files with 820 additions and 275 deletions

View File

@@ -9,7 +9,7 @@
"scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"test": "node --test \"src/**/*.test.js\"",
"dev": "quasar dev",
"build": "quasar build",
"postinstall": "quasar prepare"
@@ -41,4 +41,4 @@
"yarn": ">= 1.21.1",
"pnpm": ">= 10.0.0"
}
}
}

View File

@@ -0,0 +1,19 @@
import mockDictionaries from 'src/mocks/dictionaries.json'
import { waitForMockApi } from 'src/services/apiMode'
export async function fetchDictionaries() {
await waitForMockApi()
return normalizeDictionaries(mockDictionaries)
}
function normalizeDictionaries(data) {
return {
models: data.models ?? [],
clients: data.clients ?? [],
finishes: data.finishes ?? [],
productionLists: data.productionLists ?? [],
operations: data.operations ?? [],
colors: data.colors ?? [],
}
}

View File

@@ -0,0 +1,43 @@
import mockSpecifications from 'src/mocks/specifications.json'
import { waitForMockApi } from 'src/services/apiMode'
export async function fetchProductSpecification(productId) {
await waitForMockApi()
return normalizeSpecificationResponse(getMockSpecification(productId))
}
export async function refreshProductSpecification(productId) {
await waitForMockApi()
return normalizeSpecificationResponse({
...getMockSpecification(productId),
lastFetchedAt: new Date().toISOString(),
})
}
function getMockSpecification(productId) {
return (
mockSpecifications[productId] ?? {
productId,
orderId: null,
sourceUrl: null,
lastFetchedAt: null,
specification: {
sections: [],
},
diff: [],
}
)
}
function normalizeSpecificationResponse(data) {
return {
productId: data.productId,
orderId: data.orderId ?? null,
sourceUrl: data.sourceUrl ?? null,
lastFetchedAt: data.lastFetchedAt ?? null,
sections: data.specification?.sections ?? data.sections ?? [],
diff: data.diff ?? [],
}
}

View File

@@ -0,0 +1,81 @@
import mockTimelines from 'src/mocks/timelines.json'
import { waitForMockApi } from 'src/services/apiMode'
const timelinesByProductId = structuredClone(mockTimelines)
export async function fetchProductTimeline(productId) {
await waitForMockApi()
return getMockTimeline(productId)
}
export async function createProductTimelineEvent(productId, payload) {
await waitForMockApi()
const timeline = getMockTimeline(productId)
const event = createMockEvent(productId, payload)
timeline.events.push(event)
timeline.timelinePreview = createTimelinePreview(timeline.events)
return {
event,
timelinePreview: timeline.timelinePreview,
}
}
function getMockTimeline(productId) {
if (!timelinesByProductId[productId]) {
timelinesByProductId[productId] = {
productId,
events: [],
timelinePreview: {
body: [],
neck: [],
},
}
}
return timelinesByProductId[productId]
}
function createMockEvent(productId, payload) {
const now = Date.now()
return {
id: now,
productId,
partId: payload.partId ?? null,
partType: normalizePartType(payload.partType),
type: payload.type ?? 'operation',
operationId: payload.operationId ?? null,
operationCode: payload.operationCode ?? payload.code ?? 'NEW',
operationName: payload.operationName ?? payload.label ?? 'Nowa operacja',
date: payload.date ?? new Date().toISOString().slice(0, 10),
note: payload.note ?? null,
photosCount: payload.photosCount ?? 0,
}
}
function createTimelinePreview(events) {
return {
body: createPartPreview(events, 'BODY'),
neck: createPartPreview(events, 'NECK'),
}
}
function createPartPreview(events, partType) {
return events
.filter((event) => event.type === 'operation' && event.partType === partType)
.map((event) => ({
id: event.id,
code: event.operationCode,
label: event.operationName,
date: event.date,
status: 'done',
}))
}
function normalizePartType(partType) {
return String(partType ?? 'BODY').toUpperCase()
}

View File

@@ -0,0 +1,83 @@
import mockProducts from 'src/mocks/products.json'
import { waitForMockApi } from 'src/services/apiMode'
const mockItems = [...mockProducts.items]
export async function fetchProducts(params) {
await waitForMockApi()
const filteredItems = filterMockProducts(mockItems, params)
const offset = Number(params?.offset ?? 0)
const limit = Number(params?.limit ?? filteredItems.length)
const items = filteredItems.slice(offset, offset + limit)
return {
items,
pageInfo: {
limit,
offset,
hasMore: offset + limit < filteredItems.length,
total: filteredItems.length,
},
}
}
function filterMockProducts(items, params = {}) {
return items.filter((product) => {
if (!matchesOrderSearch(product, params)) {
return false
}
if (params.finish && product.finish !== params.finish) {
return false
}
if (params.year && product.orderYear !== Number(params.year)) {
return false
}
if (params.productionList && !product.productionLists.includes(params.productionList)) {
return false
}
return true
})
}
function matchesOrderSearch(product, params = {}) {
const search = params.search?.trim()
const orderSearch = params.orderSearch ?? []
if (!search) {
return true
}
if (!orderSearch.length) {
return false
}
return orderSearch.some((condition) => matchesOrderCondition(product, condition))
}
function matchesOrderCondition(product, condition) {
const productOrderNumber = Number(product.orderNumber)
const productOrderNumberText = String(productOrderNumber)
if (condition.type === 'orderNumberPrefix') {
return productOrderNumberText.startsWith(condition.orderNumberPrefix)
}
if (condition.orderNumber !== null && productOrderNumber !== condition.orderNumber) {
return false
}
if (condition.orderYear !== null && product.orderYear !== condition.orderYear) {
return false
}
if (condition.orderIndex !== null && product.orderIndex !== condition.orderIndex) {
return false
}
return true
}

View File

@@ -78,7 +78,7 @@ import { useProductsStore } from 'src/stores/productsStore'
const productsStore = useProductsStore()
const uiStore = useUiStore()
const searchQuery = ref(productsStore.filters.search)
const searchQuery = ref(productsStore.searchQuery)
const activeMonth = ref('all')
const monthFilters = [
@@ -98,7 +98,7 @@ onMounted(() => {
})
watch(searchQuery, (value) => {
productsStore.setFilters({ search: value })
productsStore.setSearchQuery(value)
})
function openAdvancedSearch() {
@@ -135,7 +135,7 @@ function openProductionStatuses({ product, partType } = {}) {
}
function applySearch() {
productsStore.applyFilters({ search: searchQuery.value })
productsStore.applySearch(searchQuery.value)
}
function selectMonth(month) {

View File

@@ -1,26 +1,5 @@
import { api } from 'src/boot/axios'
import mockDictionaries from 'src/mocks/dictionaries.json'
import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode'
import { fetchDictionaries as fetchDictionariesFromHttp } from 'src/services/dictionariesHttpApi'
import { fetchDictionaries as fetchDictionariesFromMock } from 'src/mocks/api/dictionariesMockApi'
import { USE_MOCK_API } from 'src/services/apiMode'
export async function fetchDictionaries() {
if (USE_MOCK_API) {
await waitForMockApi()
return normalizeDictionaries(mockDictionaries)
}
const response = await api.get('/mayo-api/dictionaries')
return normalizeDictionaries(response.data)
}
function normalizeDictionaries(data) {
return {
models: data.models ?? [],
clients: data.clients ?? [],
finishes: data.finishes ?? [],
productionLists: data.productionLists ?? [],
operations: data.operations ?? [],
colors: data.colors ?? [],
}
}
export const fetchDictionaries = USE_MOCK_API ? fetchDictionariesFromMock : fetchDictionariesFromHttp

View File

@@ -0,0 +1,18 @@
import { api } from 'src/boot/axios'
export async function fetchDictionaries() {
const response = await api.get('/mayo-api/dictionaries')
return normalizeDictionaries(response.data)
}
function normalizeDictionaries(data) {
return {
models: data.models ?? [],
clients: data.clients ?? [],
finishes: data.finishes ?? [],
productionLists: data.productionLists ?? [],
operations: data.operations ?? [],
colors: data.colors ?? [],
}
}

View File

@@ -1,56 +1,17 @@
import { api } from 'src/boot/axios'
import mockSpecifications from 'src/mocks/specifications.json'
import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode'
import {
fetchProductSpecification as fetchProductSpecificationFromHttp,
refreshProductSpecification as refreshProductSpecificationFromHttp,
} from 'src/services/productSpecificationHttpApi'
import {
fetchProductSpecification as fetchProductSpecificationFromMock,
refreshProductSpecification as refreshProductSpecificationFromMock,
} from 'src/mocks/api/productSpecificationMockApi'
import { USE_MOCK_API } from 'src/services/apiMode'
function normalizeSpecificationResponse(data) {
return {
productId: data.productId,
orderId: data.orderId ?? null,
sourceUrl: data.sourceUrl ?? null,
lastFetchedAt: data.lastFetchedAt ?? null,
sections: data.specification?.sections ?? data.sections ?? [],
diff: data.diff ?? [],
}
}
export const fetchProductSpecification = USE_MOCK_API
? fetchProductSpecificationFromMock
: fetchProductSpecificationFromHttp
export async function fetchProductSpecification(productId) {
if (USE_MOCK_API) {
await waitForMockApi()
return normalizeSpecificationResponse(getMockSpecification(productId))
}
const response = await api.get(`/mayo-api/products/${productId}/specification`)
return normalizeSpecificationResponse(response.data)
}
export async function refreshProductSpecification(productId) {
if (USE_MOCK_API) {
await waitForMockApi()
return normalizeSpecificationResponse({
...getMockSpecification(productId),
lastFetchedAt: new Date().toISOString(),
})
}
const response = await api.post(`/mayo-api/products/${productId}/specification/refresh`)
return normalizeSpecificationResponse(response.data)
}
function getMockSpecification(productId) {
return (
mockSpecifications[productId] ?? {
productId,
orderId: null,
sourceUrl: null,
lastFetchedAt: null,
specification: {
sections: [],
},
diff: [],
}
)
}
export const refreshProductSpecification = USE_MOCK_API
? refreshProductSpecificationFromMock
: refreshProductSpecificationFromHttp

View File

@@ -0,0 +1,24 @@
import { api } from 'src/boot/axios'
export async function fetchProductSpecification(productId) {
const response = await api.get(`/mayo-api/products/${productId}/specification`)
return normalizeSpecificationResponse(response.data)
}
export async function refreshProductSpecification(productId) {
const response = await api.post(`/mayo-api/products/${productId}/specification/refresh`)
return normalizeSpecificationResponse(response.data)
}
function normalizeSpecificationResponse(data) {
return {
productId: data.productId,
orderId: data.orderId ?? null,
sourceUrl: data.sourceUrl ?? null,
lastFetchedAt: data.lastFetchedAt ?? null,
sections: data.specification?.sections ?? data.sections ?? [],
diff: data.diff ?? [],
}
}

View File

@@ -1,104 +1,17 @@
import { api } from 'src/boot/axios'
import mockTimelines from 'src/mocks/timelines.json'
import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode'
import {
createProductTimelineEvent as createProductTimelineEventFromHttp,
fetchProductTimeline as fetchProductTimelineFromHttp,
} from 'src/services/productTimelineHttpApi'
import {
createProductTimelineEvent as createProductTimelineEventFromMock,
fetchProductTimeline as fetchProductTimelineFromMock,
} from 'src/mocks/api/productTimelineMockApi'
import { USE_MOCK_API } from 'src/services/apiMode'
const timelinesByProductId = structuredClone(mockTimelines)
export const fetchProductTimeline = USE_MOCK_API
? fetchProductTimelineFromMock
: fetchProductTimelineFromHttp
export async function fetchProductTimeline(productId) {
if (USE_MOCK_API) {
await waitForMockApi()
return timelinesByProductId[productId] ?? {
productId,
events: [],
timelinePreview: {
body: [],
neck: [],
},
}
}
const response = await api.get(`/mayo-api/products/${productId}/timeline`)
return response.data
}
export async function createProductTimelineEvent(productId, payload) {
if (USE_MOCK_API) {
await waitForMockApi()
const timeline = ensureMockTimeline(productId)
const event = createMockEvent(productId, payload)
timeline.events.push(event)
timeline.timelinePreview = createTimelinePreview(timeline.events)
return {
event,
timelinePreview: timeline.timelinePreview,
}
}
const response = await api.post(`/mayo-api/products/${productId}/timeline/events`, payload)
return {
event: response.data.event ?? response.data,
timelinePreview: response.data.timelinePreview,
}
}
function ensureMockTimeline(productId) {
if (!timelinesByProductId[productId]) {
timelinesByProductId[productId] = {
productId,
events: [],
timelinePreview: {
body: [],
neck: [],
},
}
}
return timelinesByProductId[productId]
}
function createMockEvent(productId, payload) {
const now = Date.now()
return {
id: now,
productId,
partId: payload.partId ?? null,
partType: normalizePartType(payload.partType),
type: payload.type ?? 'operation',
operationId: payload.operationId ?? null,
operationCode: payload.operationCode ?? payload.code ?? 'NEW',
operationName: payload.operationName ?? payload.label ?? 'Nowa operacja',
date: payload.date ?? new Date().toISOString().slice(0, 10),
note: payload.note ?? null,
photosCount: payload.photosCount ?? 0,
}
}
function createTimelinePreview(events) {
return {
body: createPartPreview(events, 'BODY'),
neck: createPartPreview(events, 'NECK'),
}
}
function createPartPreview(events, partType) {
return events
.filter((event) => event.type === 'operation' && event.partType === partType)
.map((event) => ({
id: event.id,
code: event.operationCode,
label: event.operationName,
date: event.date,
status: 'done',
}))
}
function normalizePartType(partType) {
return String(partType ?? 'BODY').toUpperCase()
}
export const createProductTimelineEvent = USE_MOCK_API
? createProductTimelineEventFromMock
: createProductTimelineEventFromHttp

View File

@@ -0,0 +1,16 @@
import { api } from 'src/boot/axios'
export async function fetchProductTimeline(productId) {
const response = await api.get(`/mayo-api/products/${productId}/timeline`)
return response.data
}
export async function createProductTimelineEvent(productId, payload) {
const response = await api.post(`/mayo-api/products/${productId}/timeline/events`, payload)
return {
event: response.data.event ?? response.data,
timelinePreview: response.data.timelinePreview,
}
}

View File

@@ -1,64 +1,5 @@
import { api } from 'src/boot/axios'
import mockProducts from 'src/mocks/products.json'
import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode'
import { fetchProducts as fetchProductsFromHttp } from 'src/services/productsHttpApi'
import { fetchProducts as fetchProductsFromMock } from 'src/mocks/api/productsMockApi'
import { USE_MOCK_API } from 'src/services/apiMode'
const mockItems = [...mockProducts.items]
export async function fetchProducts(params) {
if (USE_MOCK_API) {
await waitForMockApi()
const filteredItems = filterMockProducts(mockItems, params)
const offset = Number(params?.offset ?? 0)
const limit = Number(params?.limit ?? filteredItems.length)
const items = filteredItems.slice(offset, offset + limit)
return {
items,
pageInfo: {
limit,
offset,
hasMore: offset + limit < filteredItems.length,
total: filteredItems.length,
},
}
}
const response = await api.get('/mayo-api/products', { params })
return {
items: response.data.items ?? response.data,
pageInfo: response.data.pageInfo ?? {},
}
}
function filterMockProducts(items, params = {}) {
return items.filter((product) => {
const search = params.search?.toLowerCase()
const productionList = params.productionList
if (search && !matchesSearch(product, search)) {
return false
}
if (params.finish && product.finish !== params.finish) {
return false
}
if (params.year && product.orderYear !== Number(params.year)) {
return false
}
if (productionList && !product.productionLists.includes(productionList)) {
return false
}
return true
})
}
function matchesSearch(product, search) {
return [product.orderId, product.model, product.client, product.finish]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(search))
}
export const fetchProducts = USE_MOCK_API ? fetchProductsFromMock : fetchProductsFromHttp

View File

@@ -0,0 +1,19 @@
import { api } from 'src/boot/axios'
export async function fetchProducts(params) {
const response = await api.get('/mayo-api/products', {
params: serializeProductsParams(params),
})
return {
items: response.data.items ?? response.data,
pageInfo: response.data.pageInfo ?? {},
}
}
function serializeProductsParams(params = {}) {
return {
...params,
orderSearch: params.orderSearch?.length ? JSON.stringify(params.orderSearch) : undefined,
}
}

View File

@@ -1,12 +1,12 @@
import { computed, ref } from 'vue'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { fetchProducts } from 'src/services/productsApi'
import { parseOrderSearchQuery } from 'src/utils/orderSearchParser'
const DEFAULT_LIMIT = 30
function createDefaultFilters() {
return {
search: '',
modelId: null,
clientId: null,
finish: null,
@@ -19,6 +19,8 @@ export const useProductsStore = defineStore('products', () => {
const ids = ref([])
const byId = ref({})
const filters = ref(createDefaultFilters())
const searchQuery = ref('')
const orderSearch = ref([])
const limit = ref(DEFAULT_LIMIT)
const offset = ref(0)
const total = ref(null)
@@ -33,7 +35,8 @@ export const useProductsStore = defineStore('products', () => {
return {
limit: limit.value,
offset: offset.value,
search: filters.value.search || undefined,
search: searchQuery.value || undefined,
orderSearch: orderSearch.value.length ? orderSearch.value : undefined,
model: filters.value.modelId || undefined,
client: filters.value.clientId || undefined,
finish: filters.value.finish || undefined,
@@ -95,11 +98,21 @@ export const useProductsStore = defineStore('products', () => {
}
}
function setSearchQuery(value) {
searchQuery.value = value
orderSearch.value = parseOrderSearchQuery(value)
}
async function applyFilters(nextFilters) {
setFilters(nextFilters)
await fetchFirstPage()
}
async function applySearch(value) {
setSearchQuery(value)
await fetchFirstPage()
}
function updateProduct(productId, patch) {
const current = byId.value[productId]
@@ -133,6 +146,8 @@ export const useProductsStore = defineStore('products', () => {
ids,
byId,
filters,
searchQuery,
orderSearch,
limit,
offset,
total,
@@ -144,7 +159,9 @@ export const useProductsStore = defineStore('products', () => {
fetchFirstPage,
fetchNextPage,
setFilters,
setSearchQuery,
applyFilters,
applySearch,
updateProduct,
applyTimelinePreviewUpdate,
clear,

View File

@@ -0,0 +1,141 @@
const TOKEN_SEPARATOR_PATTERN = /[\s,.]+/
const ORDER_NUMBER_PATTERN = /^\d{1,4}$/
const YEAR_PATTERN = /^\d{4}$/
const ORDER_INDEX_PATTERN = /^\d{1,2}$/
const MIN_ORDER_YEAR = 2021
export function parseOrderSearchQuery(query) {
return tokenizeSearchQuery(query).map(parseOrderSearchToken).filter(Boolean)
}
export function tokenizeSearchQuery(query) {
return String(query ?? '')
.trim()
.split(TOKEN_SEPARATOR_PATTERN)
.map((token) => token.trim())
.filter(Boolean)
}
export function parseOrderSearchToken(token) {
const parts = token.split('/').map((part) => part.trim())
if (parts.length === 1) {
return parseOrderNumberOnly(parts[0], token)
}
if (parts.length === 2) {
return parseTwoPartOrderToken(parts, token)
}
if (parts.length === 3) {
return parseFullOrderToken(parts, token)
}
return null
}
function parseOrderNumberOnly(orderNumberText, raw) {
if (!isValidOrderNumber(orderNumberText)) {
return null
}
const normalizedOrderNumber = normalizeNumber(orderNumberText)
const hasLeadingZeros = orderNumberText.length > 1 && orderNumberText.startsWith('0')
const isFullWidthOrderNumber = orderNumberText.length === 4
if (hasLeadingZeros || isFullWidthOrderNumber) {
return {
raw,
type: 'orderNumber',
match: 'exact',
orderNumber: normalizedOrderNumber,
orderNumberPrefix: null,
orderYear: null,
orderIndex: null,
}
}
return {
raw,
type: 'orderNumberPrefix',
match: 'prefix',
orderNumber: null,
orderNumberPrefix: orderNumberText,
orderYear: null,
orderIndex: null,
}
}
function parseTwoPartOrderToken(parts, raw) {
const [orderNumberText, secondPart] = parts
if (!isValidOrderNumber(orderNumberText)) {
return null
}
if (isValidOrderYear(secondPart)) {
return {
raw,
type: 'orderNumberYear',
match: 'exact',
orderNumber: normalizeNumber(orderNumberText),
orderNumberPrefix: null,
orderYear: normalizeNumber(secondPart),
orderIndex: null,
}
}
if (isValidOrderIndex(secondPart)) {
return {
raw,
type: 'orderNumberIndex',
match: 'exact',
orderNumber: normalizeNumber(orderNumberText),
orderNumberPrefix: null,
orderYear: null,
orderIndex: normalizeNumber(secondPart),
}
}
return null
}
function parseFullOrderToken(parts, raw) {
const [orderNumberText, orderYearText, orderIndexText] = parts
if (
!isValidOrderNumber(orderNumberText) ||
!isValidOrderYear(orderYearText) ||
!isValidOrderIndex(orderIndexText)
) {
return null
}
return {
raw,
type: 'fullOrderId',
match: 'exact',
orderNumber: normalizeNumber(orderNumberText),
orderNumberPrefix: null,
orderYear: normalizeNumber(orderYearText),
orderIndex: normalizeNumber(orderIndexText),
}
}
function isValidOrderNumber(value) {
return ORDER_NUMBER_PATTERN.test(value) && normalizeNumber(value) >= 1
}
function isValidOrderYear(value) {
return YEAR_PATTERN.test(value) && normalizeNumber(value) >= MIN_ORDER_YEAR
}
function isValidOrderIndex(value) {
const normalized = normalizeNumber(value)
return ORDER_INDEX_PATTERN.test(value) && normalized >= 1 && normalized <= 99
}
function normalizeNumber(value) {
return Number.parseInt(value, 10)
}

View File

@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
parseOrderSearchQuery,
parseOrderSearchToken,
tokenizeSearchQuery,
} from './orderSearchParser.js'
describe('tokenizeSearchQuery', () => {
it('splits tokens by spaces, commas and dots', () => {
assert.deepEqual(tokenizeSearchQuery('123/1 0200/2,333/2025/3.444/2024'), [
'123/1',
'0200/2',
'333/2025/3',
'444/2024',
])
})
it('ignores empty input', () => {
assert.deepEqual(tokenizeSearchQuery(' , . '), [])
})
})
describe('parseOrderSearchToken', () => {
it('parses full order id', () => {
assert.deepEqual(parseOrderSearchToken('0012/2025/01'), {
raw: '0012/2025/01',
type: 'fullOrderId',
match: 'exact',
orderNumber: 12,
orderNumberPrefix: null,
orderYear: 2025,
orderIndex: 1,
})
})
it('parses padded order number as exact order number', () => {
assert.deepEqual(parseOrderSearchToken('0012'), {
raw: '0012',
type: 'orderNumber',
match: 'exact',
orderNumber: 12,
orderNumberPrefix: null,
orderYear: null,
orderIndex: null,
})
})
it('parses unpadded order number as prefix search', () => {
assert.deepEqual(parseOrderSearchToken('12'), {
raw: '12',
type: 'orderNumberPrefix',
match: 'prefix',
orderNumber: null,
orderNumberPrefix: '12',
orderYear: null,
orderIndex: null,
})
})
it('parses order number with year', () => {
assert.deepEqual(parseOrderSearchToken('444/2024'), {
raw: '444/2024',
type: 'orderNumberYear',
match: 'exact',
orderNumber: 444,
orderNumberPrefix: null,
orderYear: 2024,
orderIndex: null,
})
})
it('parses order number with product index', () => {
assert.deepEqual(parseOrderSearchToken('12/01'), {
raw: '12/01',
type: 'orderNumberIndex',
match: 'exact',
orderNumber: 12,
orderNumberPrefix: null,
orderYear: null,
orderIndex: 1,
})
})
it('rejects invalid values', () => {
assert.equal(parseOrderSearchToken('0000'), null)
assert.equal(parseOrderSearchToken('12/2019/1'), null)
assert.equal(parseOrderSearchToken('12/2025/100'), null)
assert.equal(parseOrderSearchToken('12/2025/1/2'), null)
})
})
describe('parseOrderSearchQuery', () => {
it('parses many order numbers in one query', () => {
assert.deepEqual(parseOrderSearchQuery('123/1 0200/2 333/2025/3 444/2024'), [
{
raw: '123/1',
type: 'orderNumberIndex',
match: 'exact',
orderNumber: 123,
orderNumberPrefix: null,
orderYear: null,
orderIndex: 1,
},
{
raw: '0200/2',
type: 'orderNumberIndex',
match: 'exact',
orderNumber: 200,
orderNumberPrefix: null,
orderYear: null,
orderIndex: 2,
},
{
raw: '333/2025/3',
type: 'fullOrderId',
match: 'exact',
orderNumber: 333,
orderNumberPrefix: null,
orderYear: 2025,
orderIndex: 3,
},
{
raw: '444/2024',
type: 'orderNumberYear',
match: 'exact',
orderNumber: 444,
orderNumberPrefix: null,
orderYear: 2024,
orderIndex: null,
},
])
})
})

View File

@@ -34,9 +34,26 @@ a service powinien zajac sie reszta.
```txt
frontend/src/services/apiMode.js
frontend/src/services/productsApi.js
frontend/src/services/productsHttpApi.js
frontend/src/services/productTimelineApi.js
frontend/src/services/productTimelineHttpApi.js
frontend/src/services/productSpecificationApi.js
frontend/src/services/productSpecificationHttpApi.js
frontend/src/services/dictionariesApi.js
frontend/src/services/dictionariesHttpApi.js
```
Pliki `*Api.js` sa fasadami. Wybieraja implementacje na podstawie `USE_MOCK_API`.
Pliki `*HttpApi.js` sa implementacja prawdziwego API przez Axios.
Implementacje mockow sa poza katalogiem `services`:
```txt
frontend/src/mocks/api/productsMockApi.js
frontend/src/mocks/api/productTimelineMockApi.js
frontend/src/mocks/api/productSpecificationMockApi.js
frontend/src/mocks/api/dictionariesMockApi.js
```
## Przeplyw danych
@@ -57,6 +74,7 @@ Przyklad:
IndexPage.vue
-> productsStore.fetchFirstPage()
-> fetchProducts(params)
-> productsMockApi albo productsHttpApi
-> products.json albo GET /mayo-api/products
```
@@ -90,6 +108,20 @@ To jest lepsze, bo:
- latwiej dodac mocki,
- latwiej testowac logike store.
Po ostatnim porzadkowaniu services sa fasadami. Przyklad:
```js
export const fetchProducts = USE_MOCK_API ? fetchProductsFromMock : fetchProductsFromHttp
```
Dzieki temu `productsStore` zawsze importuje to samo:
```js
import { fetchProducts } from 'src/services/productsApi'
```
ale realna implementacja moze byc mockowa albo HTTP.
## `apiMode.js`
Plik:
@@ -140,18 +172,19 @@ frontend/src/mocks/specifications.json
frontend/src/mocks/dictionaries.json
```
Sa uzywane tylko przez warstwe `services`.
Sa uzywane tylko przez implementacje mock API w `frontend/src/mocks/api/`.
Komponenty i store nie importuja mockow bezposrednio.
Komponenty, store i fasady `services/*Api.js` nie importuja plikow JSON bezposrednio.
To jest wazne, bo komponent nie powinien wiedziec, skad przyszly dane. Dla komponentu dane z mocka i z backendu powinny wygladac identycznie.
## `productsApi.js`
## `productsApi.js` i `productsHttpApi.js`
Plik:
```txt
frontend/src/services/productsApi.js
frontend/src/services/productsHttpApi.js
```
Eksportuje:
@@ -167,6 +200,14 @@ Odpowiedzialnosc:
- obsluga `limit` i `offset`,
- zwrocenie danych w formacie oczekiwanym przez `productsStore`.
`productsApi.js` jest fasada.
`productsHttpApi.js` zna Axios i endpoint:
```http
GET /mayo-api/products
```
Zwraca:
```js
@@ -181,14 +222,20 @@ Zwraca:
}
```
W trybie mock implementacja jest w:
```txt
frontend/src/mocks/api/productsMockApi.js
```
W trybie mock:
- czyta `frontend/src/mocks/products.json`,
- filtruje po `search`, `finish`, `year`, `productionList`,
- filtruje po `orderSearch`, `finish`, `year`, `productionList`,
- ucina wynik wedlug `limit` i `offset`,
- zwraca `items` i `pageInfo`.
W trybie prawdziwego API:
W trybie prawdziwego API implementacja jest w `productsHttpApi.js`:
```http
GET /mayo-api/products
@@ -201,6 +248,7 @@ z parametrami:
limit,
offset,
search,
orderSearch,
model,
client,
finish,
@@ -209,12 +257,21 @@ z parametrami:
}
```
## `productTimelineApi.js`
`orderSearch` powstaje w `productsStore` przez parser:
```txt
frontend/src/utils/orderSearchParser.js
```
Do HTTP `orderSearch` jest serializowane jako JSON string, bo endpoint listy jest typu `GET`.
## `productTimelineApi.js` i `productTimelineHttpApi.js`
Plik:
```txt
frontend/src/services/productTimelineApi.js
frontend/src/services/productTimelineHttpApi.js
```
Eksportuje:
@@ -228,13 +285,19 @@ createProductTimelineEvent(productId, payload)
Pobiera pelny timeline produktu.
W trybie mock implementacja jest w:
```txt
frontend/src/mocks/api/productTimelineMockApi.js
```
W trybie mock:
- czyta `frontend/src/mocks/timelines.json`,
- zwraca timeline dla `productId`,
- jesli brak danych, zwraca pusty timeline.
W trybie prawdziwego API:
W trybie prawdziwego API implementacja jest w `productTimelineHttpApi.js`:
```http
GET /mayo-api/products/:id/timeline
@@ -274,12 +337,13 @@ To pozwala store od razu zaktualizowac:
- pelny timeline,
- preview na glownej karcie produktu.
## `productSpecificationApi.js`
## `productSpecificationApi.js` i `productSpecificationHttpApi.js`
Plik:
```txt
frontend/src/services/productSpecificationApi.js
frontend/src/services/productSpecificationHttpApi.js
```
Eksportuje:
@@ -293,11 +357,17 @@ refreshProductSpecification(productId)
Pobiera specyfikacje produktu.
W trybie mock implementacja jest w:
```txt
frontend/src/mocks/api/productSpecificationMockApi.js
```
W trybie mock:
- czyta `frontend/src/mocks/specifications.json`.
W trybie prawdziwego API:
W trybie prawdziwego API implementacja jest w `productSpecificationHttpApi.js`:
```http
GET /mayo-api/products/:id/specification
@@ -353,12 +423,13 @@ data.sections
Dzieki temu store nie musi znac szczegolow odpowiedzi HTTP.
## `dictionariesApi.js`
## `dictionariesApi.js` i `dictionariesHttpApi.js`
Plik:
```txt
frontend/src/services/dictionariesApi.js
frontend/src/services/dictionariesHttpApi.js
```
Eksportuje:
@@ -372,11 +443,17 @@ Odpowiedzialnosc:
- pobranie slownikow do filtrow i formularzy,
- normalizacja pustych pol do pustych tablic.
W trybie mock implementacja jest w:
```txt
frontend/src/mocks/api/dictionariesMockApi.js
```
W trybie mock:
- czyta `frontend/src/mocks/dictionaries.json`.
W trybie prawdziwego API:
W trybie prawdziwego API implementacja jest w `dictionariesHttpApi.js`:
```http
GET /mayo-api/dictionaries
@@ -405,20 +482,28 @@ Zrob nowy service:
```txt
frontend/src/services/productPhotosApi.js
frontend/src/services/productPhotosHttpApi.js
frontend/src/mocks/api/productPhotosMockApi.js
```
W nim:
Fasada:
```js
import { fetchProductPhotos as fetchProductPhotosFromHttp } from 'src/services/productPhotosHttpApi'
import { fetchProductPhotos as fetchProductPhotosFromMock } from 'src/mocks/api/productPhotosMockApi'
import { USE_MOCK_API } from 'src/services/apiMode'
export const fetchProductPhotos = USE_MOCK_API
? fetchProductPhotosFromMock
: fetchProductPhotosFromHttp
```
Implementacja HTTP:
```js
import { api } from 'src/boot/axios'
import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode'
export async function fetchProductPhotos(productId) {
if (USE_MOCK_API) {
await waitForMockApi()
return []
}
const response = await api.get(`/mayo-api/products/${productId}/photos`)
return response.data
}
@@ -463,12 +548,16 @@ Utworzono:
```txt
frontend/src/services/apiMode.js
frontend/src/services/productsApi.js
frontend/src/services/productsHttpApi.js
frontend/src/services/productTimelineApi.js
frontend/src/services/productTimelineHttpApi.js
frontend/src/services/productSpecificationApi.js
frontend/src/services/productSpecificationHttpApi.js
frontend/src/services/dictionariesApi.js
frontend/src/services/dictionariesHttpApi.js
```
Utworzono mocki:
Utworzono dane mockow:
```txt
frontend/src/mocks/products.json
@@ -477,6 +566,15 @@ frontend/src/mocks/specifications.json
frontend/src/mocks/dictionaries.json
```
Utworzono implementacje mock API:
```txt
frontend/src/mocks/api/productsMockApi.js
frontend/src/mocks/api/productTimelineMockApi.js
frontend/src/mocks/api/productSpecificationMockApi.js
frontend/src/mocks/api/dictionariesMockApi.js
```
Zaktualizowano store tak, aby uzywaly services zamiast Axiosa bezposrednio.
Zaktualizowano widok glownej strony tak, aby pobieral dane ze store zamiast trzymac lokalne mocki w komponencie.

View File

@@ -182,7 +182,6 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać
},
filters: {
search: '',
modelId: null,
clientId: null,
finish: null,
@@ -190,6 +189,19 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać
year: null,
},
searchQuery: '123/1',
orderSearch: [
{
raw: '123/1',
type: 'orderNumberIndex',
match: 'exact',
orderNumber: 123,
orderNumberPrefix: null,
orderYear: null,
orderIndex: 1,
},
],
limit: 30,
offset: 30,
total: 184,
@@ -204,7 +216,7 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać
Endpoint:
```http
GET /mayo-api/products?limit=30&offset=0&search=regius
GET /mayo-api/products?limit=30&offset=0&search=123/1
```
Odpowiedź:
@@ -261,10 +273,22 @@ await productsStore.fetchNextPage()
Pobiera kolejną porcję produktów. Używaj tego przy lazy loading, gdy użytkownik zbliża się do końca listy.
```js
await productsStore.applyFilters({ search: 'regius' })
await productsStore.applyFilters({ finish: 'GLOSS' })
```
Ustawia filtry i pobiera listę od początku.
Ustawia filtry i pobiera listę od początku. Searchbar nie jest zwyklym filtrem, bo ma osobny parser numerow zamowien.
```js
productsStore.setSearchQuery('123/1')
```
Zapisuje tekst z searchbara i parsuje go do `orderSearch`.
```js
await productsStore.applySearch('123/1')
```
Zapisuje tekst searchbara, parsuje go i pobiera listę od początku.
```js
productsStore.applyTimelinePreviewUpdate(productId, timelinePreview)
@@ -914,7 +938,7 @@ Komponent wywołuje:
```js
productsStore.applyFilters({
search: 'regius',
finish: 'GLOSS',
})
```
@@ -924,6 +948,14 @@ Store:
2. resetuje offset,
3. pobiera pierwszą stronę listy od nowa.
Searchbar jest obslugiwany osobno:
```js
productsStore.applySearch('123/1')
```
To jest celowe, bo wyszukiwanie po numerze zamowienia ma wlasny parser i nie powinno mieszac sie z filtrami typu model, klient, finish albo lista produkcyjna.
### 4. Użytkownik otwiera specyfikację produktu
Komponent wywołuje:

26
questions/serachbar.md Normal file
View File

@@ -0,0 +1,26 @@
# zachowanie paska wyszukiwania
Pasek wyszukiwnia sluzy do wyszukiwania produktow na liscie produktow.
wyszukiwanie odbywa sie głównie po numerze zamoiwnia. Kazdy produkt ma unikalny numer zamowiania w postaci XXXX/YYYY/ZZ, gdzie:
- XXXX to numer zamowinia i przyjmuje wartosci od 1 do 1000. moze miec wiądoące zera.
- YYYY to rok zlozenia zamówniea i przyjmuje wartosci wieksze od 2020.
- ZZ to numer produktu w zamowieniu. moze miec wiadace zero, ale nie musi.
1. wyszukiwanie moze odbywac sie po pelnym nuemrze (0012/2025/1). lsita produktow powinna zaweirac jeden (gdy jest pasujace dopasowanie) lub zero produktów, jeżeli żaden produkt nie ma takiego numeru.
2. wyszukiwanie moze odbywac sie po samym numerze zamowienai (0012). lista produktów poinna zawierac wszystkie produkty o numerze 12, z każdego roku.
3. wyszukiwanie moze odbywac sie po skroconym zapisie (0012/1). lista produktow powinna zawierac liste produktow o pasujacym numerze i pozycji w zamowieniu z kazdego roku.
4. wyszukiwanie moze odbywac sie po numerze zamowinia bez wiadacych zer. na przykład:
- 1 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 1
- 12 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 12
- 123 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 123
5. wyszukiwanie moze odbywac sie po numerze zamowinia bez wiadacych zer wraz z pozycja porduktu w zamoieniu. na przykład:
- 1/1 wszystkie produkty gdzie numer zamowienia to 1, a pozycja produktu to 1. rok dowolny
- 12/1 wszystkie produkty gdzie numer zamowienia to 12, a pozycja produktu to 1. rok dowolny
- 123/1 wszystkie produkty gdzie numer zamowienia to 123, a pozycja produktu to 1. rok dowolny
- 1/01 wszystkie produkty gdzie numer zamowienia to 1, a pozycja produktu to 1. rok dowolny
- 12/01 wszystkie produkty gdzie numer zamowienia to 12, a pozycja produktu to 1. rok dowolny
- 123/01 wszystkie produkty gdzie numer zamowienia to 123, a pozycja produktu to 1. rok dowolny
6. wyszukiwanie moze odbywac podajac wiele numerow zamowien. separatorem może być spacja, kropka czy przecinek. na przykład:
"123/1 0200/2 333/2025/3 444/2024"
7. zawartosc pola wyszukiwania nie wplywa na filtry. filrtry takie jak model, klient, finish, lista prukdcyjna czy rok, sa ustawiane niezaleznie.