feat(M04-A): 完成定价快照与并发时段预占

This commit is contained in:
Codex
2026-06-18 16:07:00 +08:00
parent 6be7fa79c5
commit af7a45d878
19 changed files with 824 additions and 22 deletions
+5
View File
@@ -29,6 +29,7 @@ import {
registerStoreAccessRoutes,
type StoreAccessRouteOptions
} from './routes/store-access.js';
import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -39,6 +40,7 @@ export interface BuildAppOptions {
content?: ContentRouteOptions;
storeDiscovery?: StoreDiscoveryRouteOptions;
storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions;
}
declare module 'fastify' {
@@ -102,6 +104,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.storeAccess) {
await registerStoreAccessRoutes(app, options.storeAccess);
}
if (options.pricing) {
await registerPricingRoutes(app, options.pricing);
}
return app;
}
+7 -3
View File
@@ -30,7 +30,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
'database/migrations/2026061809_m03c_store_discovery.up.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql'
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
'database/migrations/2026061811_m04a_pricing_reservations.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -42,9 +43,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
'database/migrations/2026061809_m03c_store_discovery.verify.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql'
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql'
],
down: [
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
'database/migrations/2026061809_m03c_store_discovery.down.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
@@ -180,7 +183,8 @@ export async function executeMigrationPlan(
3, 6, 13, 1,
3, 3, 1,
2, 2, 1,
3, 3, 1
3, 3, 1,
2, 1, 3, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+307
View File
@@ -0,0 +1,307 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY';
export interface PricingAdjustment {
discountCents?: number;
packageCreditCents?: number;
}
export interface QuoteInput {
tenantId: string;
roomId: string;
startAt: Date;
endAt: Date;
pricingMode: PricingMode;
adjustment?: PricingAdjustment;
}
interface RoomPricingRow extends RowDataPacket {
id: string;
storeId: string;
timezone: string;
configurationStatus: string;
operationalStatus: string;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceDays: number;
}
interface CountRow extends RowDataPacket { total: number }
export class PricingError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PricingRepository {
constructor(private readonly pool: MySqlPool) {}
async quote(input: QuoteInput) {
const room = await this.loadRoom(this.pool, input.tenantId, input.roomId);
await this.releaseExpired(input.tenantId, input.roomId);
await this.assertAvailable(this.pool, input, false);
const isHoliday = await this.isHoliday(this.pool, input.tenantId, input.startAt, room.timezone);
return this.calculate(room, input, isHoliday);
}
async reserve(input: QuoteInput & {
userId: string;
holdMinutes?: number;
}) {
return this.transaction(async (connection) => {
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
await this.releaseExpired(input.tenantId, input.roomId, connection);
await this.assertAvailable(connection, input, true);
const isHoliday = await this.isHoliday(
connection, input.tenantId, input.startAt, room.timezone
);
const quote = this.calculate(room, input, isHoliday);
const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30);
const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [orderResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
hold_expires_at, total_amount_cents)
VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?,
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`,
[input.tenantId, room.storeId, input.roomId, orderNo, input.startAt, input.endAt,
holdMinutes, quote.totalCents]
);
const orderId = String(orderResult.insertId);
await connection.execute(
`INSERT INTO qipai_order_price_snapshots
(tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents,
subtotal_cents, minimum_spend_cents, deposit_cents, discount_cents,
package_credit_cents, total_cents, rules)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, orderId, input.pricingMode, quote.durationMinutes,
quote.unitPriceCents, quote.subtotalCents, quote.minimumSpendCents,
quote.depositCents, quote.discountCents, quote.packageCreditCents,
quote.totalCents, JSON.stringify(quote.rules)]
);
const [reservationResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_room_reservations
(tenant_id, order_id, room_id, starts_at, ends_at, expires_at)
VALUES (?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE))`,
[input.tenantId, orderId, input.roomId, input.startAt, input.endAt, holdMinutes]
);
await connection.execute(
`INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id)
VALUES (?, ?, ?)`,
[input.tenantId, orderId, input.userId]
);
return {
orderId,
orderNo,
reservationId: String(reservationResult.insertId),
holdMinutes,
quote
};
});
}
async releaseExpired(
tenantId?: string,
roomId?: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const filters = [`r.status = 'HELD'`, 'r.expires_at <= UTC_TIMESTAMP(3)'];
const params: string[] = [];
if (tenantId) {
filters.push('r.tenant_id = ?');
params.push(tenantId);
}
if (roomId) {
filters.push('r.room_id = ?');
params.push(roomId);
}
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations r
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`UPDATE qipai_room_reservations r
INNER JOIN qipai_orders o
ON o.id = r.order_id AND o.tenant_id = r.tenant_id
SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3),
o.status = 'CLOSED'
WHERE ${filters.join(' AND ')}`,
params
);
return { released: Number(counts[0]?.total ?? 0) };
}
private calculate(room: RoomPricingRow, input: QuoteInput, isHoliday: boolean) {
this.validateWindow(room, input);
const durationMinutes = Math.ceil(
(input.endAt.getTime() - input.startAt.getTime()) / 60000
);
const localDate = localDateKey(input.startAt, room.timezone);
const weekday = localWeekday(input.startAt, room.timezone);
const adjustment = input.adjustment ?? {};
let unitPriceCents = room.basePriceCents;
let priceSource = 'base';
if (input.pricingMode === 'OVERNIGHT') {
unitPriceCents = room.overnightPriceCents || room.basePriceCents;
priceSource = 'overnight';
} else if (input.pricingMode === 'FULL_DAY') {
unitPriceCents = room.fullDayPriceCents || room.basePriceCents * 24;
priceSource = 'fullDay';
} else if (isHoliday && room.holidayPriceCents > 0) {
unitPriceCents = room.holidayPriceCents;
priceSource = 'holiday';
} else if (weekday >= 1 && weekday <= 5 && room.weekdayPriceCents > 0) {
unitPriceCents = room.weekdayPriceCents;
priceSource = 'weekday';
}
const subtotalCents = input.pricingMode === 'HOURLY'
? Math.ceil(durationMinutes / 60) * unitPriceCents
: unitPriceCents;
const minimumSpendCents = room.minimumSpendCents;
const billableCents = Math.max(subtotalCents, minimumSpendCents);
const discountCents = clampAdjustment(adjustment.discountCents, billableCents);
const afterDiscount = billableCents - discountCents;
const packageCreditCents = clampAdjustment(
adjustment.packageCreditCents, afterDiscount
);
const totalCents = afterDiscount - packageCreditCents + room.depositCents;
return {
durationMinutes,
unitPriceCents,
subtotalCents,
minimumSpendCents,
depositCents: room.depositCents,
discountCents,
packageCreditCents,
totalCents,
rules: {
priceSource,
isHoliday,
localDate,
timezone: room.timezone,
pricingMode: input.pricingMode,
minimumMinutes: room.minimumMinutes
}
};
}
private validateWindow(room: RoomPricingRow, input: QuoteInput) {
if (input.endAt <= input.startAt) throw new PricingError('INVALID_TIME_WINDOW');
const durationMinutes = (input.endAt.getTime() - input.startAt.getTime()) / 60000;
if (durationMinutes < room.minimumMinutes) throw new PricingError('MINIMUM_DURATION_NOT_MET');
if (input.startAt.getTime() > Date.now() + room.maxAdvanceDays * 86400000) {
throw new PricingError('ADVANCE_WINDOW_EXCEEDED');
}
if (room.configurationStatus !== 'ENABLED' || room.operationalStatus !== 'AVAILABLE') {
throw new PricingError('ROOM_NOT_AVAILABLE');
}
}
private async loadRoom(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
roomId: string,
lock = false
) {
const [rows] = await connection.execute<RoomPricingRow[]>(
`SELECT r.id, r.store_id AS storeId, s.timezone,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus,
r.base_price_cents AS basePriceCents,
r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_days AS maxAdvanceDays
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, roomId]
);
if (!rows[0]) throw new PricingError('ROOM_NOT_FOUND');
return rows[0];
}
private async assertAvailable(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
input: QuoteInput,
lock: boolean
) {
const [disabled] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_disabled_periods
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ?`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(disabled[0]?.total ?? 0) > 0) throw new PricingError('ROOM_DISABLED_PERIOD');
const [reserved] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations
WHERE tenant_id = ? AND room_id = ?
AND status IN ('HELD', 'CONSUMED')
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
AND starts_at < ? AND ends_at > ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(reserved[0]?.total ?? 0) > 0) throw new PricingError('TIME_SLOT_CONFLICT');
}
private async isHoliday(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
date: Date,
timezone: string
) {
const [rows] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_holiday_calendar
WHERE tenant_id = ? AND holiday_date = ?`,
[tenantId, localDateKey(date, timezone)]
);
return Number(rows[0]?.total ?? 0) > 0;
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function clampAdjustment(value: number | undefined, maximum: number) {
if (!value || value < 0) return 0;
return Math.min(Math.trunc(value), maximum);
}
function localDateKey(date: Date, timezone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).format(date);
}
function localWeekday(date: Date, timezone: string) {
const day = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, weekday: 'short'
}).format(date);
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(day);
}
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { PricingError, type PricingRepository } from '../orders/pricing-repository.js';
const requestSchema = z.object({
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
}).refine((value) => value.endAt > value.startAt);
export interface PricingRouteOptions {
repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerPricingRoutes(app: FastifyInstance, options: PricingRouteOptions) {
app.post('/app-api/pricing/quote', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.quote({
tenantId: auth.tenantId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/orders/reserve', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.reserve({
tenantId: auth.tenantId,
userId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/reservations/release-expired', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
return reply.status(403).send({
code: 'RESERVATION_RELEASE_FORBIDDEN',
message: 'Tenant management permission is required.',
traceId: request.traceId
});
}
return {
code: 0,
data: await options.repository.releaseExpired(auth.tenantId),
traceId: request.traceId
};
});
}
async function authenticate(
authorization: string | undefined,
options: PricingRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
return {
tenantId: result.session.tenantId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PricingError)) throw error;
const conflict = error.code === 'TIME_SLOT_CONFLICT';
const notFound = error.code === 'ROOM_NOT_FOUND';
return reply.status(conflict ? 409 : notFound ? 404 : 400).send({
code: error.code,
message: 'The requested price or time slot is not available.',
traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRICING_REQUEST',
message: 'The pricing request is invalid.',
traceId
});
}
@@ -44,6 +44,8 @@ const roomSchema = z.object({
weekdayPriceCents: z.number().int().min(0).max(10000000),
holidayPriceCents: z.number().int().min(0).max(10000000),
overnightPriceCents: z.number().int().min(0).max(10000000),
fullDayPriceCents: z.number().int().min(0).max(10000000).default(0),
minimumSpendCents: z.number().int().min(0).max(10000000).default(0),
depositCents: z.number().int().min(0).max(10000000),
minimumMinutes: z.number().int().min(15).max(1440),
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
+7
View File
@@ -12,6 +12,7 @@ import { MediaStorage } from './content/media-storage.js';
import { resolve } from 'node:path';
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
import { StoreAccessRepository } from './stores/access-repository.js';
import { PricingRepository } from './orders/pricing-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -56,6 +57,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
pricing: {
repository: new PricingRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+18 -8
View File
@@ -34,6 +34,8 @@ export interface RoomInput {
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
@@ -56,7 +58,8 @@ interface StoreRow extends RowDataPacket {
interface RoomRow extends RowDataPacket {
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: number;
overnightPriceCents: number; depositCents: number; minimumMinutes: number;
overnightPriceCents: number; fullDayPriceCents: number; minimumSpendCents: number;
depositCents: number; minimumMinutes: number;
maxAdvanceStartMinutes: number; maxAdvanceDays: number; configurationStatus: string;
operationalStatus: string; tags: string | string[] | null; images: string | string[] | null;
sortOrder: number;
@@ -155,7 +158,10 @@ export class StoreRoomRepository {
r.name, r.room_no AS roomNo, r.capacity,
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.overnight_price_cents AS overnightPriceCents, r.deposit_cents AS depositCents,
r.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
r.max_advance_days AS maxAdvanceDays,
@@ -185,10 +191,11 @@ export class StoreRoomRepository {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_rooms
(tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents,
weekday_price_cents, holiday_price_cents, overnight_price_cents, deposit_cents,
minimum_minutes, max_advance_start_minutes, max_advance_days,
weekday_price_cents, holiday_price_cents, overnight_price_cents,
full_day_price_cents, minimum_spend_cents, deposit_cents, minimum_minutes,
max_advance_start_minutes, max_advance_days,
configuration_status, operational_status, tags, images, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
roomParams(actor.tenantId, categoryId, input)
);
const roomId = String(result.insertId);
@@ -207,7 +214,8 @@ export class StoreRoomRepository {
await connection.execute(
`UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?,
base_price_cents = ?, weekday_price_cents = ?, holiday_price_cents = ?,
overnight_price_cents = ?, deposit_cents = ?, minimum_minutes = ?,
overnight_price_cents = ?, full_day_price_cents = ?, minimum_spend_cents = ?,
deposit_cents = ?, minimum_minutes = ?,
max_advance_start_minutes = ?, max_advance_days = ?, configuration_status = ?,
operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ?
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
@@ -369,7 +377,8 @@ function roomParams(tenantId: string, categoryId: string, input: RoomInput) {
return [
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents,
input.overnightPriceCents, input.depositCents, input.minimumMinutes,
input.overnightPriceCents, input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0,
input.depositCents, input.minimumMinutes,
input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus,
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
input.sortOrder, legacyStatus
@@ -382,7 +391,8 @@ function roomUpdateParams(categoryId: string, input: RoomInput) {
return [
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents,
input.depositCents, input.minimumMinutes, input.maxAdvanceStartMinutes,
input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0, input.depositCents,
input.minimumMinutes, input.maxAdvanceStartMinutes,
input.maxAdvanceDays, input.configurationStatus, input.operationalStatus,
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
];